diff options
Diffstat (limited to 'toolkit/components/downloads')
31 files changed, 14661 insertions, 0 deletions
diff --git a/toolkit/components/downloads/DownloadCore.sys.mjs b/toolkit/components/downloads/DownloadCore.sys.mjs new file mode 100644 index 0000000000..1711a01784 --- /dev/null +++ b/toolkit/components/downloads/DownloadCore.sys.mjs @@ -0,0 +1,3082 @@ +/* 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/. */ + +/** + * Main implementation of the Downloads API objects. Consumers should get + * references to these objects through the "Downloads.sys.mjs" module. + */ + +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadHistory: "resource://gre/modules/DownloadHistory.sys.mjs", + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gExternalAppLauncher", + "@mozilla.org/uriloader/external-helper-app-service;1", + Ci.nsPIExternalAppLauncher +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gExternalHelperAppService", + "@mozilla.org/uriloader/external-helper-app-service;1", + Ci.nsIExternalHelperAppService +); + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const BackgroundFileSaverStreamListener = Components.Constructor( + "@mozilla.org/network/background-file-saver;1?mode=streamlistener", + "nsIBackgroundFileSaver" +); + +/** + * Returns true if the given value is a primitive string or a String object. + */ +function isString(aValue) { + // We cannot use the "instanceof" operator reliably across module boundaries. + return ( + typeof aValue == "string" || + (typeof aValue == "object" && "charAt" in aValue) + ); +} + +/** + * Serialize the unknown properties of aObject into aSerializable. + */ +function serializeUnknownProperties(aObject, aSerializable) { + if (aObject._unknownProperties) { + for (let property in aObject._unknownProperties) { + aSerializable[property] = aObject._unknownProperties[property]; + } + } +} + +/** + * Check for any unknown properties in aSerializable and preserve those in the + * _unknownProperties field of aObject. aFilterFn is called for each property + * name of aObject and should return true only for unknown properties. + */ +function deserializeUnknownProperties(aObject, aSerializable, aFilterFn) { + for (let property in aSerializable) { + if (aFilterFn(property)) { + if (!aObject._unknownProperties) { + aObject._unknownProperties = {}; + } + + aObject._unknownProperties[property] = aSerializable[property]; + } + } +} + +/** + * Check if the file is a placeholder. + * + * @return {Promise} + * @resolves {boolean} + * @rejects Never. + */ +async function isPlaceholder(path) { + try { + if ((await IOUtils.stat(path)).size == 0) { + return true; + } + } catch (ex) { + // Canceling the download may have removed the placeholder already. + if (ex.name != "NotFoundError") { + console.error(ex); + } + } + return false; +} + +/** + * This determines the minimum time interval between updates to the number of + * bytes transferred, and is a limiting factor to the sequence of readings used + * in calculating the speed of the download. + */ +const kProgressUpdateIntervalMs = 400; + +/** + * Represents a single download, with associated state and actions. This object + * is transient, though it can be included in a DownloadList so that it can be + * managed by the user interface and persisted across sessions. + */ +export var Download = function () { + this._deferSucceeded = Promise.withResolvers(); +}; + +Download.prototype = { + /** + * DownloadSource object associated with this download. + */ + source: null, + + /** + * DownloadTarget object associated with this download. + */ + target: null, + + /** + * DownloadSaver object associated with this download. + */ + saver: null, + + /** + * Indicates that the download never started, has been completed successfully, + * failed, or has been canceled. This property becomes false when a download + * is started for the first time, or when a failed or canceled download is + * restarted. + */ + stopped: true, + + /** + * Indicates that the download has been completed successfully. + */ + succeeded: false, + + /** + * Indicates that the download has been canceled. This property can become + * true, then it can be reset to false when a canceled download is restarted. + * + * This property becomes true as soon as the "cancel" method is called, though + * the "stopped" property might remain false until the cancellation request + * has been processed. Temporary files or part files may still exist even if + * they are expected to be deleted, until the "stopped" property becomes true. + */ + canceled: false, + + /** + * Downloaded files can be deleted from within Firefox, e.g. via the context + * menu. Currently Firefox does not track file moves (see bug 1746386), so if + * a download's target file stops existing we have to assume it's "moved or + * missing." To distinguish files intentionally deleted within Firefox from + * files that are moved/missing, we mark them as "deleted" with this property. + */ + deleted: false, + + /** + * When the download fails, this is set to a DownloadError instance indicating + * the cause of the failure. If the download has been completed successfully + * or has been canceled, this property is null. This property is reset to + * null when a failed download is restarted. + */ + error: null, + + /** + * Indicates the start time of the download. When the download starts, + * this property is set to a valid Date object. The default value is null + * before the download starts. + */ + startTime: null, + + /** + * Indicates whether this download's "progress" property is able to report + * partial progress while the download proceeds, and whether the value in + * totalBytes is relevant. This depends on the saver and the download source. + */ + hasProgress: false, + + /** + * Progress percent, from 0 to 100. Intermediate values are reported only if + * hasProgress is true. + * + * @note You shouldn't rely on this property being equal to 100 to determine + * whether the download is completed. You should use the individual + * state properties instead. + */ + progress: 0, + + /** + * When hasProgress is true, indicates the total number of bytes to be + * transferred before the download finishes, that can be zero for empty files. + * + * When hasProgress is false, this property is always zero. + * + * @note This property may be different than the final file size on disk for + * downloads that are encoded during the network transfer. You can use + * the "size" property of the DownloadTarget object to get the actual + * size on disk once the download succeeds. + */ + totalBytes: 0, + + /** + * Number of bytes currently transferred. This value starts at zero, and may + * be updated regardless of the value of hasProgress. + * + * @note You shouldn't rely on this property being equal to totalBytes to + * determine whether the download is completed. You should use the + * individual state properties instead. This property may not be + * updated during the last part of the download. + */ + currentBytes: 0, + + /** + * Fractional number representing the speed of the download, in bytes per + * second. This value is zero when the download is stopped, and may be + * updated regardless of the value of hasProgress. + */ + speed: 0, + + /** + * 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. + * + * Whether partial data can actually be retained depends on the saver and the + * download source, and may not be known before the download is started. + */ + 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. + */ + onchange: null, + + /** + * This tells if the user has chosen to open/run the downloaded file after + * download has completed. + */ + launchWhenSucceeded: false, + + /** + * When a download starts, we typically want to automatically open the + * downloads panel if the pref browser.download.alwaysOpenPanel is enabled. + * However, there are conditions where we want to prevent this. For example, a + * false value can prevent the downloads panel from opening when an add-on + * creates a download without user input as part of some background operation. + */ + openDownloadsListOnStart: true, + + /** + * This represents the MIME type of the download. + */ + contentType: null, + + /** + * This indicates the path of the application to be used to launch the file, + * or null if the file should be launched with the default application. + */ + launcherPath: null, + + /** + * Raises the onchange notification. + */ + _notifyChange: function D_notifyChange() { + try { + if (this.onchange) { + this.onchange(); + } + } catch (ex) { + console.error(ex); + } + }, + + /** + * The download may be stopped and restarted multiple times before it + * completes successfully. This may happen if any of the download attempts is + * canceled or fails. + * + * This property contains a promise that is linked to the current attempt, or + * null if the download is either stopped or in the process of being canceled. + * If the download restarts, this property is replaced with a new promise. + * + * The promise is resolved if the attempt it represents finishes successfully, + * and rejected if the attempt fails. + */ + _currentAttempt: null, + + /** + * The download was launched to open from the Downloads Panel. + */ + _launchedFromPanel: false, + + /** + * Starts the download for the first time, or restarts a download that failed + * or has been canceled. + * + * Calling this method when the download has been completed successfully has + * no effect, and the method returns a resolved promise. If the download is + * in progress, the method returns the same promise as the previous call. + * + * If the "cancel" method was called but the cancellation process has not + * finished yet, this method waits for the cancellation to finish, then + * restarts the download immediately. + * + * @note If you need to start a new download from the same source, rather than + * restarting a failed or canceled one, you should create a separate + * Download object with the same source as the current one. + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ + start: function D_start() { + // If the download succeeded, it's the final state, we have nothing to do. + if (this.succeeded) { + return Promise.resolve(); + } + + // If the download already started and hasn't failed or hasn't been + // canceled, return the same promise as the previous call, allowing the + // caller to wait for the current attempt to finish. + if (this._currentAttempt) { + return this._currentAttempt; + } + + // While shutting down or disposing of this object, we prevent the download + // from returning to be in progress. + if (this._finalized) { + return Promise.reject( + new DownloadError({ + 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; + // Avoid serializing the previous error, or it would be restored on the next + // startup, even if the download was restarted. + delete this._unknownProperties?.errorObj; + this.hasProgress = false; + this.hasBlockedData = false; + this.progress = 0; + this.totalBytes = 0; + this.currentBytes = 0; + this.startTime = new Date(); + + // Create a new deferred object and an associated promise before starting + // the actual download. We store it on the download as the current attempt. + let deferAttempt = Promise.withResolvers(); + let currentAttempt = deferAttempt.promise; + this._currentAttempt = currentAttempt; + + // Restart the progress and speed calculations from scratch. + this._lastProgressTimeMs = 0; + + // This function propagates progress from the DownloadSaver object, unless + // it comes in late from a download attempt that was replaced by a new one. + // If the cancellation process for the download has started, then the update + // is ignored. + function DS_setProgressBytes(aCurrentBytes, aTotalBytes, aHasPartialData) { + if (this._currentAttempt == currentAttempt) { + this._setBytes(aCurrentBytes, aTotalBytes, aHasPartialData); + } + } + + // This function propagates download properties from the DownloadSaver + // object, unless it comes in late from a download attempt that was + // replaced by a new one. If the cancellation process for the download has + // started, then the update is ignored. + function DS_setProperties(aOptions) { + if (this._currentAttempt != currentAttempt) { + return; + } + + let changeMade = false; + + for (let property of [ + "contentType", + "progress", + "hasPartialData", + "hasBlockedData", + ]) { + if (property in aOptions && this[property] != aOptions[property]) { + this[property] = aOptions[property]; + changeMade = true; + } + } + + if (changeMade) { + this._notifyChange(); + } + } + + // Now that we stored the promise in the download object, we can start the + // task that will actually execute the download. + deferAttempt.resolve( + (async () => { + // Wait upon any pending operation before restarting. + if (this._promiseCanceled) { + await this._promiseCanceled; + } + if (this._promiseRemovePartialData) { + try { + await this._promiseRemovePartialData; + } catch (ex) { + // Ignore any errors, which are already reported by the original + // caller of the removePartialData method. + } + } + + // In case the download was restarted while cancellation was in progress, + // but the previous attempt actually succeeded before cancellation could + // be processed, it is possible that the download has already finished. + if (this.succeeded) { + return; + } + + try { + if (this.downloadingToSameFile()) { + throw new DownloadError({ + message: "Can't overwrite the source file.", + becauseTargetFailed: true, + }); + } + + // Disallow download if parental controls service restricts it. + if ( + await lazy.DownloadIntegration.shouldBlockForParentalControls(this) + ) { + throw new DownloadError({ becauseBlockedByParentalControls: true }); + } + + // We should check if we have been canceled in the meantime, after all + // the previous asynchronous operations have been executed and just + // before we call the "execute" method of the saver. + if (this._promiseCanceled) { + // The exception will become a cancellation in the "catch" block. + throw new Error(undefined); + } + + // Execute the actual download through the saver object. + this._saverExecuting = true; + try { + await this.saver.execute( + DS_setProgressBytes.bind(this), + DS_setProperties.bind(this) + ); + } catch (ex) { + // Remove the target file placeholder and all partial data when + // needed, independently of which code path failed. In some cases, the + // component executing the download may have already removed the file. + if (!this.hasPartialData && !this.hasBlockedData) { + await this.saver.removeData(true); + } + throw ex; + } + + // Now that the actual saving finished, read the actual file size on + // disk, that may be different from the amount of data transferred. + await this.target.refresh(); + + // Check for the last time if the download has been canceled. This must + // be done right before setting the "stopped" property of the download, + // without any asynchronous operations in the middle, so that another + // cancellation request cannot start in the meantime and stay unhandled. + if (this._promiseCanceled) { + // To keep the internal state of the Download object consistent, we + // just delete the target and effectively cancel the download. Since + // the DownloadSaver succeeded, we already renamed the ".part" file to + // the final name, and this results in all the data being deleted. + await this.saver.removeData(true); + + // Cancellation exceptions will be changed in the catch block below. + throw new DownloadError(); + } + + // Update the status properties for a successful download. + this.progress = 100; + this.succeeded = true; + this.hasPartialData = false; + } catch (originalEx) { + // We may choose a different exception to propagate in the code below, + // or wrap the original one. We do this mutation in a different variable + // because of the "no-ex-assign" ESLint rule. + let ex = originalEx; + + // Fail with a generic status code on cancellation, so that the caller + // is forced to actually check the status properties to see if the + // download was canceled or failed because of other reasons. + if (this._promiseCanceled) { + throw new DownloadError({ message: "Download canceled." }); + } + + // An HTTP 450 error code is used by Windows to indicate that a uri is + // blocked by parental controls. This will prevent the download from + // occuring, so an error needs to be raised. This is not performed + // during the parental controls check above as it requires the request + // to start. + if (this._blockedByParentalControls) { + ex = new DownloadError({ becauseBlockedByParentalControls: true }); + } + + // Update the download error, unless a new attempt already started. The + // change in the status property is notified in the finally block. + if (this._currentAttempt == currentAttempt || !this._currentAttempt) { + if (!(ex instanceof DownloadError)) { + let properties = { innerException: ex }; + + if (ex.message) { + properties.message = ex.message; + } + + ex = new DownloadError(properties); + } + // Don't store an error if it's an abort caused by shutdown, so the + // download can be retried automatically at the next startup. + if ( + originalEx.result != Cr.NS_ERROR_ABORT || + !Services.startup.isInOrBeyondShutdownPhase( + Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED + ) + ) { + this.error = ex; + } + } + throw ex; + } finally { + // Any cancellation request has now been processed. + this._saverExecuting = false; + this._promiseCanceled = null; + + // Update the status properties, unless a new attempt already started. + if (this._currentAttempt == currentAttempt || !this._currentAttempt) { + this._currentAttempt = null; + this.stopped = true; + this.speed = 0; + this._notifyChange(); + if (this.succeeded) { + await this._succeed(); + } + } + } + })() + ); + + // 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. + */ + async _succeed() { + await lazy.DownloadIntegration.downloadDone(this); + + this._deferSucceeded.resolve(); + + if (this.launchWhenSucceeded) { + this.launch().catch(console.error); + + // Always schedule files to be deleted at the end of the private browsing + // mode, regardless of the value of the pref. + if (this.source.isPrivate) { + lazy.gExternalAppLauncher.deleteTemporaryPrivateFileWhenPossible( + new lazy.FileUtils.File(this.target.path) + ); + } else if ( + Services.prefs.getBoolPref("browser.helperApps.deleteTempFileOnExit") && + Services.prefs.getBoolPref( + "browser.download.start_downloads_in_tmp_dir", + false + ) + ) { + lazy.gExternalAppLauncher.deleteTemporaryFileOnExit( + new lazy.FileUtils.File(this.target.path) + ); + } + } + }, + + /** + * 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() { + if (this._promiseUnblock) { + return this._promiseUnblock; + } + + if (this._promiseConfirmBlock) { + return Promise.reject( + new Error("Download block has been confirmed, cannot unblock.") + ); + } + + if (this.error?.becauseBlockedByReputationCheck) { + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(this.error.reputationCheckVerdict, 2); // unblock + } + + if ( + this.error?.reputationCheckVerdict == DownloadError.BLOCK_VERDICT_INSECURE + ) { + // In this Error case, the download was actually canceled before it was + // passed to the Download UI. So we need to start the download here. + this.error = null; + this.succeeded = false; + this.hasBlockedData = false; + // This ensures the verdict will not get set again after the browser + // restarts and the download gets serialized and de-serialized again. + delete this._unknownProperties?.errorObj; + this.start() + .catch(err => { + if (err.becauseTargetFailed) { + // In case we cannot write to the target file + // retry with a new unique name + let uniquePath = lazy.DownloadPaths.createNiceUniqueFile( + new lazy.FileUtils.File(this.target.path) + ).path; + this.target.path = uniquePath; + return this.start(); + } + return Promise.reject(err); + }) + .catch(err => { + if (!this.canceled) { + console.error(err); + } + this._notifyChange(); + }); + this._notifyChange(); + this._promiseUnblock = lazy.DownloadIntegration.downloadDone(this); + return this._promiseUnblock; + } + + if (!this.hasBlockedData) { + return Promise.reject( + new Error("unblock may only be called on Downloads with blocked data.") + ); + } + + this._promiseUnblock = (async () => { + try { + await IOUtils.move(this.target.partFilePath, this.target.path); + await this.target.refresh(); + } catch (ex) { + await this.refresh(); + this._promiseUnblock = null; + throw ex; + } + + this.succeeded = true; + this.hasBlockedData = false; + this._notifyChange(); + await this._succeed(); + })(); + + 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() { + if (this._promiseConfirmBlock) { + return this._promiseConfirmBlock; + } + + if (this._promiseUnblock) { + return Promise.reject( + new Error("Download is being unblocked, cannot confirmBlock.") + ); + } + + if (this.error?.becauseBlockedByReputationCheck) { + // We have to record the telemetry in both DownloadsCommon.deleteDownload + // and confirmBlock here. The former is for cases where users click + // "Remove file" in the download panel and the latter is when + // users click "X" button in about:downloads. + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(this.error.reputationCheckVerdict, 1); // confirm block + } + + if (!this.hasBlockedData) { + return Promise.reject( + new Error( + "confirmBlock may only be called on Downloads with blocked data." + ) + ); + } + + this._promiseConfirmBlock = (async () => { + // This call never throws exceptions. If the removal fails, the blocked + // data remains stored on disk in the ".part" file. + await this.saver.removeData(); + + this.hasBlockedData = false; + this._notifyChange(); + })(); + + return this._promiseConfirmBlock; + }, + + /* + * Launches the file after download has completed. This can open + * the file with the default application for the target MIME type + * or file extension, or with a custom application if launcherPath + * is set. + * + * @param options.openWhere Optional string indicating how to open when handling + * download by opening the target file URI. + * One of "window", "tab", "tabshifted" + * @param options.useSystemDefault + * Optional value indicating how to handle launching this download, + * this time only. Will override the associated mimeInfo.preferredAction + * @return {Promise} + * @resolves When the instruction to launch the file has been + * successfully given to the operating system. Note that + * the OS might still take a while until the file is actually + * launched. + * @rejects JavaScript exception if there was an error trying to launch + * the file. + */ + launch(options = {}) { + if (!this.succeeded) { + return Promise.reject( + new Error("launch can only be called if the download succeeded") + ); + } + + if (this._launchedFromPanel) { + Services.telemetry.scalarAdd("downloads.file_opened", 1); + } + + return lazy.DownloadIntegration.launchDownload(this, options); + }, + + /* + * Shows the folder containing the target file, or where the target file + * will be saved. This may be called at any time, even if the download + * failed or is currently in progress. + * + * @return {Promise} + * @resolves When the instruction to open the containing folder has been + * successfully given to the operating system. Note that + * the OS might still take a while until the folder is actually + * opened. + * @rejects JavaScript exception if there was an error trying to open + * the containing folder. + */ + showContainingDirectory: function D_showContainingDirectory() { + return lazy.DownloadIntegration.showContainingDirectory(this.target.path); + }, + + /** + * When a request to cancel the download is received, contains a promise that + * will be resolved when the cancellation request is processed. When the + * request is processed, this property becomes null again. + */ + _promiseCanceled: null, + + /** + * True between the call to the "execute" method of the saver and the + * completion of the current download attempt. + */ + _saverExecuting: false, + + /** + * Cancels the download. + * + * The cancellation request is asynchronous. Until the cancellation process + * finishes, temporary files or part files may still exist even if they are + * expected to be deleted. + * + * In case the download completes successfully before the cancellation request + * could be processed, this method has no effect, and it returns a resolved + * promise. You should check the properties of the download at the time the + * returned promise is resolved to determine if the download was cancelled. + * + * Calling this method when the download has been completed successfully, + * failed, or has been canceled has no effect, and the method returns a + * resolved promise. This behavior is designed for the case where the call + * to "cancel" happens asynchronously, and is consistent with the case where + * the cancellation request could not be processed in time. + * + * @return {Promise} + * @resolves When the cancellation process has finished. + * @rejects Never. + */ + cancel: function D_cancel() { + // If the download is currently stopped, we have nothing to do. + if (this.stopped) { + return Promise.resolve(); + } + + if (!this._promiseCanceled) { + // Start a new cancellation request. + this._promiseCanceled = new Promise(resolve => { + this._currentAttempt.then(resolve, resolve); + }); + + // The download can already be restarted. + this._currentAttempt = null; + + // Notify that the cancellation request was received. + this.canceled = true; + this._notifyChange(); + + // Execute the actual cancellation through the saver object, in case it + // has already started. Otherwise, the cancellation will be handled just + // before the saver is started. + if (this._saverExecuting) { + this.saver.cancel(); + } + } + + return this._promiseCanceled; + }, + + /** + * Indicates whether any partially downloaded data should be retained, to use + * when restarting a failed or canceled download. The default is false. + * + * Whether partial data can actually be retained depends on the saver and the + * download source, and may not be known before the download is started. + * + * To have any effect, this property must be set before starting the download. + * Resetting this property to false after the download has already started + * will not remove any partial data. + * + * If this property is set to true, care should be taken that partial data is + * removed before the reference to the download is discarded. This can be + * done using the removePartialData or the "finalize" methods. + */ + tryToKeepPartialData: false, + + /** + * When a request to remove partially downloaded data is received, contains a + * promise that will be resolved when the removal request is processed. When + * the request is processed, this property becomes null again. + */ + _promiseRemovePartialData: null, + + /** + * Removes any partial data kept as part of a canceled or failed download. + * + * If the download is not canceled or failed, this method has no effect, and + * it returns a resolved promise. If the "cancel" method was called but the + * cancellation process has not finished yet, this method waits for the + * cancellation to finish, then removes the partial data. + * + * After this method has been called, if the tryToKeepPartialData property is + * still true when the download is restarted, partial data will be retained + * during the new download attempt. + * + * @return {Promise} + * @resolves When the partial data has been successfully removed. + * @rejects JavaScript exception if the operation could not be completed. + */ + removePartialData() { + if (!this.canceled && !this.error) { + return Promise.resolve(); + } + + if (!this._promiseRemovePartialData) { + this._promiseRemovePartialData = (async () => { + try { + // Wait upon any pending cancellation request. + if (this._promiseCanceled) { + await this._promiseCanceled; + } + // Ask the saver object to remove any partial data. + await this.saver.removeData(); + // For completeness, clear the number of bytes transferred. + if (this.currentBytes != 0 || this.hasPartialData) { + this.currentBytes = 0; + this.hasPartialData = false; + this.target.refreshPartFileState(); + this._notifyChange(); + } + } finally { + this._promiseRemovePartialData = null; + } + })(); + } + + return this._promiseRemovePartialData; + }, + + /** + * Returns true if the download source is the same as the target file. + */ + downloadingToSameFile() { + if (!this.source.url || !this.source.url.startsWith("file:")) { + return false; + } + + try { + let sourceUri = lazy.NetUtil.newURI(this.source.url); + let targetUri = lazy.NetUtil.newURI( + new lazy.FileUtils.File(this.target.path) + ); + return sourceUri.equals(targetUri); + } catch (ex) { + return false; + } + }, + + /** + * This deferred object contains a promise that is resolved as soon as this + * download finishes successfully, and is never rejected. This property is + * initialized when the download is created, and never changes. + */ + _deferSucceeded: null, + + /** + * Returns a promise that is resolved as soon as this download finishes + * successfully, even if the download was stopped and restarted meanwhile. + * + * You can use this property for scheduling download completion actions in the + * current session, for downloads that are controlled interactively. If the + * download is not controlled interactively, you should use the promise + * returned by the "start" method instead, to check for success or failure. + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects Never. + */ + whenSucceeded: function D_whenSucceeded() { + return this._deferSucceeded.promise; + }, + + /** + * Updates the state of a finished, failed, or canceled download based on the + * current state in the file system. If the download is in progress or it has + * been finalized, this method has no effect, and it returns a resolved + * promise. + * + * This allows the properties of the download to be updated in case the user + * moved or deleted the target file or its associated ".part" file. + * + * @return {Promise} + * @resolves When the operation has completed. + * @rejects Never. + */ + refresh() { + return (async () => { + if (!this.stopped || this._finalized) { + return; + } + + if (this.succeeded) { + let oldExists = this.target.exists; + let oldSize = this.target.size; + await this.target.refresh(); + if (oldExists != this.target.exists || oldSize != this.target.size) { + this._notifyChange(); + } + return; + } + + // Update the current progress from disk if we retained partial data. + if ( + (this.hasPartialData || this.hasBlockedData) && + this.target.partFilePath + ) { + try { + let stat = await IOUtils.stat(this.target.partFilePath); + + // Ignore the result if the state has changed meanwhile. + if (!this.stopped || this._finalized) { + return; + } + + // Update the bytes transferred and the related progress properties. + this.currentBytes = stat.size; + if (this.totalBytes > 0) { + this.hasProgress = true; + this.progress = Math.floor( + (this.currentBytes / this.totalBytes) * 100 + ); + } + } catch (ex) { + if (ex.name != "NotFoundError") { + throw ex; + } + // Ignore the result if the state has changed meanwhile. + if (!this.stopped || this._finalized) { + return; + } + // In case we've blocked the Download becasue its + // insecure, we should not set hasBlockedData to + // false as its required to show the Unblock option. + if ( + this.error.reputationCheckVerdict == + DownloadError.BLOCK_VERDICT_INSECURE + ) { + return; + } + + this.hasBlockedData = false; + this.hasPartialData = false; + } + + this._notifyChange(); + } + })().catch(console.error); + }, + + /** + * True if the "finalize" method has been called. This prevents the download + * from starting again after having been stopped. + */ + _finalized: false, + + /** + * True if the "finalize" has been called and fully finished it's execution. + */ + _finalizeExecuted: false, + + /** + * Ensures that the download is stopped, and optionally removes any partial + * data kept as part of a canceled or failed download. After this method has + * been called, the download cannot be started again. + * + * This method should be used in place of "cancel" and removePartialData while + * shutting down or disposing of the download object, to prevent other callers + * from interfering with the operation. This is required because cancellation + * and other operations are asynchronous. + * + * @param aRemovePartialData + * Whether any partially downloaded data should be removed after the + * download has been stopped. + * + * @return {Promise} + * @resolves When the operation has finished successfully. + * @rejects JavaScript exception if an error occurred while removing the + * partially downloaded data. + */ + finalize(aRemovePartialData) { + // Prevents the download from starting again after having been stopped. + this._finalized = true; + let promise; + + if (aRemovePartialData) { + // Cancel the download, in case it is currently in progress, then remove + // any partially downloaded data. The removal operation waits for + // cancellation to be completed before resolving the promise it returns. + this.cancel(); + promise = this.removePartialData(); + } else { + // Just cancel the download, in case it is currently in progress. + promise = this.cancel(); + } + promise.then(() => { + // At this point, either removing data / just cancelling the download should be done. + this._finalizeExecuted = true; + }); + + return promise; + }, + + /** + * Deletes all file data associated with a download, preserving the download + * object itself and updating it for download views. + */ + async manuallyRemoveData() { + let { path } = this.target; + if (this.succeeded) { + // Temp files are made "read-only" by DownloadIntegration.downloadDone, so + // reset the permission bits to read/write. This won't be necessary after + // bug 1733587 since Downloads won't ever be temporary. + await IOUtils.setPermissions(path, 0o660); + await IOUtils.remove(path, { ignoreAbsent: true }); + } + this.deleted = true; + await this.cancel(); + await this.removePartialData(); + // We need to guarantee that the UI is refreshed irrespective of what state + // the download is in when this is called, to ensure the download doesn't + // wind up stuck displaying as if it exists when it actually doesn't. And + // that means updating this.target.partFileExists no matter what. + await this.target.refreshPartFileState(); + await this.refresh(); + // The above methods will sometimes call _notifyChange, but not always. It + // depends on whether the download is `succeeded`, `stopped`, `canceled`, + // etc. Since this method needs to update the UI and can be invoked on any + // download as long as its target has some file on the system, we need to + // call _notifyChange no matter what state the download is in. + this._notifyChange(); + }, + + /** + * Indicates the time of the last progress notification, expressed as the + * number of milliseconds since January 1, 1970, 00:00:00 UTC. This is zero + * until some bytes have actually been transferred. + */ + _lastProgressTimeMs: 0, + + /** + * Updates progress notifications based on the number of bytes transferred. + * + * The number of bytes transferred is not updated unless enough time passed + * since this function was last called. This limits the computation load, in + * particular when the listeners update the user interface in response. + * + * @param aCurrentBytes + * Number of bytes transferred until now. + * @param aTotalBytes + * Total number of bytes to be transferred, or -1 if unknown. + * @param aHasPartialData + * Indicates whether the partially downloaded data can be used when + * restarting the download if it fails or is canceled. + */ + _setBytes: function D_setBytes(aCurrentBytes, aTotalBytes, aHasPartialData) { + let changeMade = this.hasPartialData != aHasPartialData; + this.hasPartialData = aHasPartialData; + + // Unless aTotalBytes is -1, we can report partial download progress. In + // this case, notify when the related properties changed since last time. + if ( + aTotalBytes != -1 && + (!this.hasProgress || this.totalBytes != aTotalBytes) + ) { + this.hasProgress = true; + this.totalBytes = aTotalBytes; + changeMade = true; + } + + // Updating the progress and computing the speed require that enough time + // passed since the last update, or that we haven't started throttling yet. + let currentTimeMs = Date.now(); + let intervalMs = currentTimeMs - this._lastProgressTimeMs; + if (intervalMs >= kProgressUpdateIntervalMs) { + // Don't compute the speed unless we started throttling notifications. + if (this._lastProgressTimeMs != 0) { + // Calculate the speed in bytes per second. + let rawSpeed = + ((aCurrentBytes - this.currentBytes) / intervalMs) * 1000; + if (this.speed == 0) { + // When the previous speed is exactly zero instead of a fractional + // number, this can be considered the first element of the series. + this.speed = rawSpeed; + } else { + // Apply exponential smoothing, with a smoothing factor of 0.1. + this.speed = rawSpeed * 0.1 + this.speed * 0.9; + } + } + + // Start throttling notifications only when we have actually received some + // bytes for the first time. The timing of the first part of the download + // is not reliable, due to possible latency in the initial notifications. + // This also allows automated tests to receive and verify the number of + // bytes initially transferred. + if (aCurrentBytes > 0) { + this._lastProgressTimeMs = currentTimeMs; + + // Update the progress now that we don't need its previous value. + this.currentBytes = aCurrentBytes; + if (this.totalBytes > 0) { + this.progress = Math.floor( + (this.currentBytes / this.totalBytes) * 100 + ); + } + changeMade = true; + } + + if (this.hasProgress && this.target && !this.target.partFileExists) { + this.target.refreshPartFileState(); + } + } + + if (changeMade) { + this._notifyChange(); + } + }, + + /** + * Returns a static representation of the current object state. + * + * @return A JavaScript object that can be serialized to JSON. + */ + toSerializable() { + let serializable = { + source: this.source.toSerializable(), + target: this.target.toSerializable(), + }; + + let saver = this.saver.toSerializable(); + if (!serializable.source || !saver) { + // If we are unable to serialize either the source or the saver, + // we won't persist the download. + return null; + } + + // Simplify the representation for the most common saver type. If the saver + // is an object instead of a simple string, we can't simplify it because we + // need to persist all its properties, not only "type". This may happen for + // savers of type "copy" as well as other types. + if (saver !== "copy") { + serializable.saver = saver; + } + + if (this.error) { + serializable.errorObj = this.error.toSerializable(); + } + + if (this.startTime) { + serializable.startTime = this.startTime.toJSON(); + } + + // These are serialized unless they are false, null, or empty strings. + for (let property of kPlainSerializableDownloadProperties) { + if (this[property]) { + serializable[property] = this[property]; + } + } + + serializeUnknownProperties(this, serializable); + + return serializable; + }, + + /** + * Returns a value that changes only when one of the properties of a Download + * object that should be saved into a file also change. This excludes + * properties whose value doesn't usually change during the download lifetime. + * + * This function is used to determine whether the download should be + * serialized after a property change notification has been received. + * + * @return String representing the relevant download state. + */ + getSerializationHash() { + // The "succeeded", "canceled", "error", and startTime properties are not + // taken into account because they all change before the "stopped" property + // changes, and are not altered in other cases. + return ( + this.stopped + + "," + + this.totalBytes + + "," + + this.hasPartialData + + "," + + this.contentType + ); + }, +}; + +/** + * Defines which properties of the Download object are serializable. + */ +const kPlainSerializableDownloadProperties = [ + "succeeded", + "canceled", + "totalBytes", + "hasPartialData", + "hasBlockedData", + "tryToKeepPartialData", + "launcherPath", + "launchWhenSucceeded", + "contentType", + "handleInternally", + "openDownloadsListOnStart", +]; + +/** + * Creates a new Download object from a serializable representation. This + * function is used by the createDownload method of Downloads.sys.mjs when a new + * Download object is requested, thus some properties may refer to live objects + * in place of their serializable representations. + * + * @param aSerializable + * An object with the following fields: + * { + * source: DownloadSource object, or its serializable representation. + * See DownloadSource.fromSerializable for details. + * target: DownloadTarget object, or its serializable representation. + * See DownloadTarget.fromSerializable for details. + * saver: Serializable representation of a DownloadSaver object. See + * DownloadSaver.fromSerializable for details. If omitted, + * defaults to "copy". + * } + * + * @return The newly created Download object. + */ +Download.fromSerializable = function (aSerializable) { + let download = new Download(); + if (aSerializable.source instanceof DownloadSource) { + download.source = aSerializable.source; + } else { + download.source = DownloadSource.fromSerializable(aSerializable.source); + } + if (aSerializable.target instanceof DownloadTarget) { + download.target = aSerializable.target; + } else { + download.target = DownloadTarget.fromSerializable(aSerializable.target); + } + if ("saver" in aSerializable) { + download.saver = DownloadSaver.fromSerializable(aSerializable.saver); + } else { + download.saver = DownloadSaver.fromSerializable("copy"); + } + download.saver.download = download; + + if ("startTime" in aSerializable) { + let time = aSerializable.startTime.getTime + ? aSerializable.startTime.getTime() + : aSerializable.startTime; + download.startTime = new Date(time); + } + + // If 'errorObj' is present it will take precedence over the 'error' property. + // 'error' is a legacy property only containing message, which is insufficient + // to represent all of the error information. + // + // Instead of just replacing 'error' we use a new 'errorObj' so that previous + // versions will keep it as an unknown property. + if ("errorObj" in aSerializable) { + download.error = DownloadError.fromSerializable(aSerializable.errorObj); + } else if ("error" in aSerializable) { + download.error = aSerializable.error; + } + + for (let property of kPlainSerializableDownloadProperties) { + if (property in aSerializable) { + download[property] = aSerializable[property]; + } + } + + deserializeUnknownProperties( + download, + aSerializable, + property => + !kPlainSerializableDownloadProperties.includes(property) && + property != "startTime" && + property != "source" && + property != "target" && + property != "error" && + property != "saver" + ); + + return download; +}; + +/** + * Represents the source of a download, for example a document or an URI. + */ +export var DownloadSource = function () {}; + +DownloadSource.prototype = { + /** + * String containing the URI for the download source. + */ + url: null, + + /** + * String containing the original URL for the download source. + */ + originalUrl: null, + + /** + * Indicates whether the download originated from a private window. This + * determines the context of the network request that is made to retrieve the + * resource. + */ + isPrivate: false, + + /** + * Represents the referrerInfo of the download source, could be null for + * example if the download source is not HTTP. + */ + referrerInfo: null, + + /** + * For downloads handled by the (default) DownloadCopySaver, this function + * can adjust the network channel before it is opened, for example to change + * the HTTP headers or to upload a stream as POST data. + * + * @note If this is defined this object will not be serializable, thus the + * Download object will not be persisted across sessions. + * + * @param aChannel + * The nsIChannel to be adjusted. + * + * @return {Promise} + * @resolves When the channel has been adjusted and can be opened. + * @rejects JavaScript exception that will cause the download to fail. + */ + adjustChannel: null, + + /** + * For downloads handled by the (default) DownloadCopySaver, this function + * will determine, if provided, if a download can progress or has to be + * cancelled based on the HTTP status code of the network channel. + * + * @note If this is defined this object will not be serializable, thus the + * Download object will not be persisted across sessions. + * + * @param aDownload + * The download asking. + * @param aStatus + * The HTTP status in question + * + * @return {Boolean} Download can progress + */ + allowHttpStatus: null, + + /** + * Represents the loadingPrincipal of the download source, + * could be null, in which case the system principal is used instead. + */ + loadingPrincipal: null, + + /** + * Represents the cookieJarSettings of the download source, could be null if + * the download source is not from a document. + */ + cookieJarSettings: null, + + /** + * Represents the authentication header of the download source, could be null if + * the download source had no authentication header. + */ + authHeader: null, + /** + * Returns a static representation of the current object state. + * + * @return A JavaScript object that can be serialized to JSON. + */ + toSerializable() { + if (this.adjustChannel) { + // If the callback was used, we can't reproduce this across sessions. + return null; + } + + if (this.allowHttpStatus) { + // If the callback was used, we can't reproduce this across sessions. + return null; + } + + let serializable = { url: this.url }; + if (this.isPrivate) { + serializable.isPrivate = true; + } + + if (this.referrerInfo && isString(this.referrerInfo)) { + serializable.referrerInfo = this.referrerInfo; + } else if (this.referrerInfo) { + serializable.referrerInfo = lazy.E10SUtils.serializeReferrerInfo( + this.referrerInfo + ); + } + + if (this.loadingPrincipal) { + serializable.loadingPrincipal = isString(this.loadingPrincipal) + ? this.loadingPrincipal + : lazy.E10SUtils.serializePrincipal(this.loadingPrincipal); + } + + if (this.cookieJarSettings) { + serializable.cookieJarSettings = isString(this.cookieJarSettings) + ? this.cookieJarSettings + : lazy.E10SUtils.serializeCookieJarSettings(this.cookieJarSettings); + } + + serializeUnknownProperties(this, serializable); + + // Simplify the representation if we don't have other details. + if (Object.keys(serializable).length === 1) { + // serializable's only key is "url", just return the URL as a string. + return this.url; + } + return serializable; + }, +}; + +/** + * Creates a new DownloadSource object from its serializable representation. + * + * @param aSerializable + * Serializable representation of a DownloadSource object. This may be a + * string containing the URI for the download source, an nsIURI, or an + * object with the following properties: + * { + * url: String containing the URI for the download source. + * isPrivate: Indicates whether the download originated from a private + * window. If omitted, the download is public. + * referrerInfo: represents the referrerInfo of the download source. + * Can be omitted or null for example if the download + * source is not HTTP. + * cookieJarSettings: represents the cookieJarSettings of the download + * source. Can be omitted or null if the download + * source is not from a document. + * adjustChannel: For downloads handled by (default) DownloadCopySaver, + * this function can adjust the network channel before + * it is opened, for example to change the HTTP headers + * or to upload a stream as POST data. Optional. + * allowHttpStatus: For downloads handled by the (default) + * DownloadCopySaver, this function will determine, if + * provided, if a download can progress or has to be + * cancelled based on the HTTP status code of the + * network channel. + * } + * + * @return The newly created DownloadSource object. + */ +DownloadSource.fromSerializable = function (aSerializable) { + let source = new DownloadSource(); + if (isString(aSerializable)) { + // Convert String objects to primitive strings at this point. + source.url = aSerializable.toString(); + } else if (aSerializable instanceof Ci.nsIURI) { + source.url = aSerializable.spec; + } else { + // Convert String objects to primitive strings at this point. + source.url = aSerializable.url.toString(); + for (let propName of ["isPrivate", "userContextId", "browsingContextId"]) { + if (propName in aSerializable) { + source[propName] = aSerializable[propName]; + } + } + if ("originalUrl" in aSerializable) { + source.originalUrl = aSerializable.originalUrl; + } + if ("referrerInfo" in aSerializable) { + // Quick pass, pass directly nsIReferrerInfo, we don't need to serialize + // and deserialize + if (aSerializable.referrerInfo instanceof Ci.nsIReferrerInfo) { + source.referrerInfo = aSerializable.referrerInfo; + } else { + source.referrerInfo = lazy.E10SUtils.deserializeReferrerInfo( + aSerializable.referrerInfo + ); + } + } + if ("loadingPrincipal" in aSerializable) { + // Quick pass, pass directly nsIPrincipal, we don't need to serialize + // and deserialize + if (aSerializable.loadingPrincipal instanceof Ci.nsIPrincipal) { + source.loadingPrincipal = aSerializable.loadingPrincipal; + } else { + source.loadingPrincipal = lazy.E10SUtils.deserializePrincipal( + aSerializable.loadingPrincipal + ); + } + } + if ("adjustChannel" in aSerializable) { + source.adjustChannel = aSerializable.adjustChannel; + } + + if ("allowHttpStatus" in aSerializable) { + source.allowHttpStatus = aSerializable.allowHttpStatus; + } + + if ("cookieJarSettings" in aSerializable) { + if (aSerializable.cookieJarSettings instanceof Ci.nsICookieJarSettings) { + source.cookieJarSettings = aSerializable.cookieJarSettings; + } else { + source.cookieJarSettings = lazy.E10SUtils.deserializeCookieJarSettings( + aSerializable.cookieJarSettings + ); + } + } + + if ("authHeader" in aSerializable) { + source.authHeader = aSerializable.authHeader; + } + + deserializeUnknownProperties( + source, + aSerializable, + property => + property != "url" && + property != "originalUrl" && + property != "isPrivate" && + property != "referrerInfo" && + property != "cookieJarSettings" && + property != "authHeader" + ); + } + + return source; +}; + +/** + * Represents the target of a download, for example a file in the global + * downloads directory, or a file in the system temporary directory. + */ +export var DownloadTarget = function () {}; + +DownloadTarget.prototype = { + /** + * String containing the path of the target file. + */ + path: null, + + /** + * String containing the path of the ".part" file containing the data + * downloaded so far, or null to disable the use of a ".part" file to keep + * partially downloaded data. + */ + partFilePath: null, + + /** + * Indicates whether the target file exists. + * + * This is a dynamic property updated when the download finishes or when the + * "refresh" method of the Download object is called. It can be used by the + * front-end to reduce I/O compared to checking the target file directly. + */ + exists: false, + + /** + * Indicates whether the part file exists. Like `exists`, this is updated + * dynamically to reduce I/O compared to checking the target file directly. + */ + partFileExists: false, + + /** + * Size in bytes of the target file, or zero if the download has not finished. + * + * Even if the target file does not exist anymore, this property may still + * have a value taken from the download metadata. If the metadata has never + * been available in this session and the size cannot be obtained from the + * file because it has already been deleted, this property will be zero. + * + * For single-file downloads, this property will always match the actual file + * size on disk, while the totalBytes property of the Download object, when + * available, may represent the size of the encoded data instead. + * + * For downloads involving multiple files, like complete web pages saved to + * disk, the meaning of this value is undefined. It currently matches the size + * of the main file only rather than the sum of all the written data. + * + * This is a dynamic property updated when the download finishes or when the + * "refresh" method of the Download object is called. It can be used by the + * front-end to reduce I/O compared to checking the target file directly. + */ + size: 0, + + /** + * Sets the "exists" and "size" properties based on the actual file on disk. + * + * @return {Promise} + * @resolves When the operation has finished successfully. + * @rejects JavaScript exception. + */ + async refresh() { + try { + this.size = (await IOUtils.stat(this.path)).size; + this.exists = true; + } catch (ex) { + // Report any error not caused by the file not being there. In any case, + // the size of the download is not updated and the known value is kept. + if (ex.name != "NotFoundError") { + console.error(ex); + } + this.exists = false; + } + this.refreshPartFileState(); + }, + + async refreshPartFileState() { + if (!this.partFilePath) { + this.partFileExists = false; + return; + } + try { + this.partFileExists = (await IOUtils.stat(this.partFilePath)).size > 0; + } catch (ex) { + if (ex.name != "NotFoundError") { + console.error(ex); + } + this.partFileExists = false; + } + }, + + /** + * Returns a static representation of the current object state. + * + * @return A JavaScript object that can be serialized to JSON. + */ + toSerializable() { + // Simplify the representation if we don't have other details. + if (!this.partFilePath && !this._unknownProperties) { + return this.path; + } + + let serializable = { path: this.path, partFilePath: this.partFilePath }; + serializeUnknownProperties(this, serializable); + return serializable; + }, +}; + +/** + * Creates a new DownloadTarget object from its serializable representation. + * + * @param aSerializable + * Serializable representation of a DownloadTarget object. This may be a + * string containing the path of the target file, an nsIFile, or an + * object with the following properties: + * { + * path: String containing the path of the target file. + * partFilePath: optional string containing the part file path. + * } + * + * @return The newly created DownloadTarget object. + */ +DownloadTarget.fromSerializable = function (aSerializable) { + let target = new DownloadTarget(); + if (isString(aSerializable)) { + // Convert String objects to primitive strings at this point. + target.path = aSerializable.toString(); + } else if (aSerializable instanceof Ci.nsIFile) { + // Read the "path" property of nsIFile after checking the object type. + target.path = aSerializable.path; + } else { + // Read the "path" property of the serializable DownloadTarget + // representation, converting String objects to primitive strings. + target.path = aSerializable.path.toString(); + if ("partFilePath" in aSerializable) { + target.partFilePath = aSerializable.partFilePath; + } + + deserializeUnknownProperties( + target, + aSerializable, + property => property != "path" && property != "partFilePath" + ); + } + return target; +}; + +/** + * Provides detailed information about a download failure. + * + * @param aProperties + * Object which may contain any of the following properties: + * { + * result: Result error code, defaulting to Cr.NS_ERROR_FAILURE + * message: String error message to be displayed, or null to use the + * message associated with the result code. + * inferCause: If true, attempts to determine if the cause of the + * download is a network failure or a local file failure, + * based on a set of known values of the result code. + * This is useful when the error is received by a + * component that handles both aspects of the download. + * } + * The properties object may also contain any of the DownloadError's + * because properties, which will be set accordingly in the error object. + */ +export var DownloadError = function (aProperties) { + const NS_ERROR_MODULE_BASE_OFFSET = 0x45; + const NS_ERROR_MODULE_NETWORK = 6; + const NS_ERROR_MODULE_FILES = 13; + + // Set the error name used by the Error object prototype first. + this.name = "DownloadError"; + this.result = aProperties.result || Cr.NS_ERROR_FAILURE; + if (aProperties.message) { + this.message = aProperties.message; + } else if ( + aProperties.becauseBlocked || + aProperties.becauseBlockedByParentalControls || + aProperties.becauseBlockedByReputationCheck + ) { + this.message = "Download blocked."; + } else { + let exception = new Components.Exception("", this.result); + this.message = exception.toString(); + } + if (aProperties.inferCause) { + let module = + ((this.result & 0x7fff0000) >> 16) - NS_ERROR_MODULE_BASE_OFFSET; + this.becauseSourceFailed = module == NS_ERROR_MODULE_NETWORK; + this.becauseTargetFailed = module == NS_ERROR_MODULE_FILES; + } else { + if (aProperties.becauseSourceFailed) { + this.becauseSourceFailed = true; + } + if (aProperties.becauseTargetFailed) { + this.becauseTargetFailed = true; + } + } + + if (aProperties.becauseBlockedByParentalControls) { + this.becauseBlocked = true; + this.becauseBlockedByParentalControls = true; + } else if (aProperties.becauseBlockedByReputationCheck) { + this.becauseBlocked = true; + this.becauseBlockedByReputationCheck = true; + this.reputationCheckVerdict = aProperties.reputationCheckVerdict || ""; + } else if (aProperties.becauseBlocked) { + this.becauseBlocked = true; + } + + if (aProperties.innerException) { + this.innerException = aProperties.innerException; + } + + this.stack = new Error().stack; +}; + +/** + * These constants are used by the reputationCheckVerdict property and indicate + * the detailed reason why a download is blocked. + * + * @note These values should not be changed because they can be serialized. + */ +DownloadError.BLOCK_VERDICT_MALWARE = "Malware"; +DownloadError.BLOCK_VERDICT_POTENTIALLY_UNWANTED = "PotentiallyUnwanted"; +DownloadError.BLOCK_VERDICT_INSECURE = "Insecure"; +DownloadError.BLOCK_VERDICT_UNCOMMON = "Uncommon"; +DownloadError.BLOCK_VERDICT_DOWNLOAD_SPAM = "DownloadSpam"; + +DownloadError.prototype = { + /** + * The result code associated with this error. + */ + result: false, + + /** + * Indicates an error occurred while reading from the remote location. + */ + becauseSourceFailed: false, + + /** + * Indicates an error occurred while writing to the local target. + */ + becauseTargetFailed: false, + + /** + * Indicates the download failed because it was blocked. If the reason for + * blocking is known, the corresponding property will be also set. + */ + becauseBlocked: false, + + /** + * Indicates the download was blocked because downloads are globally + * disallowed by the Parental Controls or Family Safety features on Windows. + */ + becauseBlockedByParentalControls: false, + + /** + * Indicates the download was blocked because it failed the reputation check + * and may be malware. + */ + becauseBlockedByReputationCheck: false, + + /** + * If becauseBlockedByReputationCheck is true, indicates the detailed reason + * why the download was blocked, according to the "BLOCK_VERDICT_" constants. + * + * If the download was not blocked or the reason for the block is unknown, + * this will be an empty string. + */ + reputationCheckVerdict: "", + + /** + * If this DownloadError was caused by an exception this property will + * contain the original exception. This will not be serialized when saving + * to the store. + */ + innerException: null, + + /** + * Returns a static representation of the current object state. + * + * @return A JavaScript object that can be serialized to JSON. + */ + toSerializable() { + let serializable = { + result: this.result, + message: this.message, + becauseSourceFailed: this.becauseSourceFailed, + becauseTargetFailed: this.becauseTargetFailed, + becauseBlocked: this.becauseBlocked, + becauseBlockedByParentalControls: this.becauseBlockedByParentalControls, + becauseBlockedByReputationCheck: this.becauseBlockedByReputationCheck, + reputationCheckVerdict: this.reputationCheckVerdict, + }; + + serializeUnknownProperties(this, serializable); + return serializable; + }, +}; +Object.setPrototypeOf(DownloadError.prototype, Error.prototype); + +/** + * Creates a new DownloadError object from its serializable representation. + * + * @param aSerializable + * Serializable representation of a DownloadError object. + * + * @return The newly created DownloadError object. + */ +DownloadError.fromSerializable = function (aSerializable) { + let e = new DownloadError(aSerializable); + deserializeUnknownProperties( + e, + aSerializable, + property => + property != "result" && + property != "message" && + property != "becauseSourceFailed" && + property != "becauseTargetFailed" && + property != "becauseBlocked" && + property != "becauseBlockedByParentalControls" && + property != "becauseBlockedByReputationCheck" && + property != "reputationCheckVerdict" + ); + + return e; +}; + +/** + * Template for an object that actually transfers the data for the download. + */ +export var DownloadSaver = function () {}; + +DownloadSaver.prototype = { + /** + * Download object for raising notifications and reading properties. + * + * If the tryToKeepPartialData property of the download object is false, the + * saver should never try to keep partially downloaded data if the download + * fails. + */ + download: null, + + /** + * Executes the download. + * + * @param aSetProgressBytesFn + * This function may be called by the saver to report progress. It + * takes three arguments: the first is the number of bytes transferred + * until now, the second is the total number of bytes to be + * transferred (or -1 if unknown), the third indicates whether the + * partially downloaded data can be used when restarting the download + * if it fails or is canceled. + * @param aSetPropertiesFn + * This function may be called by the saver to report information + * about new download properties discovered by the saver during the + * download process. It takes an object where the keys represents + * the names of the properties to set, and the value represents the + * value to set. + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ + async execute(aSetProgressBytesFn, aSetPropertiesFn) { + throw new Error("Not implemented."); + }, + + /** + * Cancels the download. + */ + cancel: function DS_cancel() { + throw new Error("Not implemented."); + }, + + /** + * Removes any target file placeholder and any partial data kept as part of a + * canceled, failed, or temporarily blocked download. + * + * This method is never called until the promise returned by "execute" is + * either resolved or rejected, and the "execute" method is not called again + * until the promise returned by this method is resolved or rejected. + * + * @param canRemoveFinalTarget + * True if can remove target file regardless of it being a placeholder. + * @return {Promise} + * @resolves When the operation has finished successfully. + * @rejects Never. + */ + async removeData(canRemoveFinalTarget) {}, + + /** + * This can be called by the saver implementation when the download is already + * started, to add it to the browsing history. This method has no effect if + * the download is private. + */ + addToHistory() { + if (AppConstants.MOZ_PLACES) { + lazy.DownloadHistory.addDownloadToHistory(this.download).catch( + console.error + ); + } + }, + + /** + * Returns a static representation of the current object state. + * + * @return A JavaScript object that can be serialized to JSON. + */ + toSerializable() { + throw new Error("Not implemented."); + }, + + /** + * Returns the SHA-256 hash of the downloaded file, if it exists. + */ + getSha256Hash() { + throw new Error("Not implemented."); + }, + + getSignatureInfo() { + throw new Error("Not implemented."); + }, +}; // DownloadSaver + +/** + * Creates a new DownloadSaver object from its serializable representation. + * + * @param aSerializable + * Serializable representation of a DownloadSaver object. If no initial + * state information for the saver object is needed, can be a string + * representing the class of the download operation, for example "copy". + * + * @return The newly created DownloadSaver object. + */ +DownloadSaver.fromSerializable = function (aSerializable) { + let serializable = isString(aSerializable) + ? { type: aSerializable } + : aSerializable; + let saver; + switch (serializable.type) { + case "copy": + saver = DownloadCopySaver.fromSerializable(serializable); + break; + case "legacy": + saver = DownloadLegacySaver.fromSerializable(serializable); + break; + default: + throw new Error("Unrecoginzed download saver type."); + } + return saver; +}; + +/** + * Saver object that simply copies the entire source file to the target. + */ +export var DownloadCopySaver = function () {}; + +DownloadCopySaver.prototype = { + /** + * BackgroundFileSaver object currently handling the download. + */ + _backgroundFileSaver: null, + + /** + * Indicates whether the "cancel" method has been called. This is used to + * prevent the request from starting in case the operation is canceled before + * the BackgroundFileSaver instance has been created. + */ + _canceled: false, + + /** + * Save the SHA-256 hash in raw bytes of the downloaded file. This is null + * unless BackgroundFileSaver has successfully completed saving the file. + */ + _sha256Hash: null, + + /** + * Save the signature info as an Array of Array of raw bytes of nsIX509Cert + * if the file is signed. This is empty if the file is unsigned, and null + * unless BackgroundFileSaver has successfully completed saving the file. + */ + _signatureInfo: null, + + /** + * Save the redirects chain as an nsIArray of nsIPrincipal. + */ + _redirects: null, + + /** + * True if the associated download has already been added to browsing history. + */ + alreadyAddedToHistory: false, + + /** + * String corresponding to the entityID property of the nsIResumableChannel + * used to execute the download, or null if the channel was not resumable or + * the saver was instructed not to keep partially downloaded data. + */ + entityID: null, + + /** + * Implements "DownloadSaver.execute". + */ + async execute(aSetProgressBytesFn, aSetPropertiesFn) { + this._canceled = false; + + let download = this.download; + let targetPath = download.target.path; + let partFilePath = download.target.partFilePath; + let keepPartialData = download.tryToKeepPartialData; + + // Add the download to history the first time it is started in this + // session. If the download is restarted in a different session, a new + // history visit will be added. We do this just to avoid the complexity + // of serializing this state between sessions, since adding a new visit + // does not have any noticeable side effect. + if (!this.alreadyAddedToHistory) { + this.addToHistory(); + this.alreadyAddedToHistory = true; + } + + // To reduce the chance that other downloads reuse the same final target + // file name, we should create a placeholder as soon as possible, before + // starting the network request. The placeholder is also required in case + // we are using a ".part" file instead of the final target while the + // download is in progress. + try { + // If the file already exists, don't delete its contents yet. + await IOUtils.writeUTF8(targetPath, "", { mode: "appendOrCreate" }); + } catch (ex) { + if (!DOMException.isInstance(ex)) { + throw ex; + } + // Throw a DownloadError indicating that the operation failed because of + // the target file. We cannot translate this into a specific result + // code, but we preserve the original message. + let error = new DownloadError({ message: ex.message }); + error.becauseTargetFailed = true; + throw error; + } + + let deferSaveComplete = Promise.withResolvers(); + + if (this._canceled) { + // Don't create the BackgroundFileSaver object if we have been + // canceled meanwhile. + throw new DownloadError({ message: "Saver canceled." }); + } + + // Create the object that will save the file in a background thread. + let backgroundFileSaver = new BackgroundFileSaverStreamListener(); + backgroundFileSaver.QueryInterface(Ci.nsIStreamListener); + + try { + // When the operation completes, reflect the status in the promise + // returned by this download execution function. + backgroundFileSaver.observer = { + onTargetChange() {}, + onSaveComplete: (aSaver, aStatus) => { + // Send notifications now that we can restart if needed. + if (Components.isSuccessCode(aStatus)) { + // Save the hash before freeing backgroundFileSaver. + this._sha256Hash = aSaver.sha256Hash; + this._signatureInfo = aSaver.signatureInfo; + this._redirects = aSaver.redirects; + deferSaveComplete.resolve(); + } else { + // Infer the origin of the error from the failure code, because + // BackgroundFileSaver does not provide more specific data. + let properties = { result: aStatus, inferCause: true }; + deferSaveComplete.reject(new DownloadError(properties)); + } + // Free the reference cycle, to release resources earlier. + backgroundFileSaver.observer = null; + this._backgroundFileSaver = null; + }, + }; + + // If we have data that we can use to resume the download from where + // it stopped, try to use it. + let resumeAttempted = false; + let resumeFromBytes = 0; + + const notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]), + getInterface: ChromeUtils.generateQI(["nsIProgressEventSink"]), + onProgress: function DCSE_onProgress( + aRequest, + aProgress, + aProgressMax + ) { + let currentBytes = resumeFromBytes + aProgress; + let totalBytes = + aProgressMax == -1 ? -1 : resumeFromBytes + aProgressMax; + aSetProgressBytesFn( + currentBytes, + totalBytes, + aProgress > 0 && partFilePath && keepPartialData + ); + }, + onStatus() {}, + }; + + const streamListener = { + onStartRequest: function (aRequest) { + backgroundFileSaver.onStartRequest(aRequest); + + if (aRequest instanceof Ci.nsIHttpChannel) { + // Check if the request's response has been blocked by Windows + // Parental Controls with an HTTP 450 error code. + if (aRequest.responseStatus == 450) { + // Set a flag that can be retrieved later when handling the + // cancellation so that the proper error can be thrown. + this.download._blockedByParentalControls = true; + aRequest.cancel(Cr.NS_BINDING_ABORTED); + return; + } + + // Check back with the initiator if we should allow a certain + // HTTP code. By default, we'll just save error pages too, + // however a consumer down the line, such as the WebExtensions + // downloads API might want to handle this differently. + if ( + download.source.allowHttpStatus && + !download.source.allowHttpStatus( + download, + aRequest.responseStatus + ) + ) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + return; + } + } + + if (aRequest instanceof Ci.nsIChannel) { + aSetPropertiesFn({ contentType: aRequest.contentType }); + + // Ensure we report the value of "Content-Length", if available, + // even if the download doesn't generate any progress events + // later. + if (aRequest.contentLength >= 0) { + aSetProgressBytesFn(0, aRequest.contentLength); + } + } + + // If the URL we are downloading from includes a file extension + // that matches the "Content-Encoding" header, for example ".gz" + // with a "gzip" encoding, we should save the file in its encoded + // form. In all other cases, we decode the body while saving. + if ( + aRequest instanceof Ci.nsIEncodedChannel && + aRequest.contentEncodings + ) { + let uri = aRequest.URI; + if (uri instanceof Ci.nsIURL && uri.fileExtension) { + // Only the first, outermost encoding is considered. + let encoding = aRequest.contentEncodings.getNext(); + if (encoding) { + aRequest.applyConversion = + lazy.gExternalHelperAppService.applyDecodingForExtension( + uri.fileExtension, + encoding + ); + } + } + } + + if (keepPartialData) { + // If the source is not resumable, don't keep partial data even + // if we were asked to try and do it. + if (aRequest instanceof Ci.nsIResumableChannel) { + try { + // If reading the ID succeeds, the source is resumable. + this.entityID = aRequest.entityID; + } catch (ex) { + if ( + !(ex instanceof Components.Exception) || + ex.result != Cr.NS_ERROR_NOT_RESUMABLE + ) { + throw ex; + } + keepPartialData = false; + } + } else { + keepPartialData = false; + } + } + + // Enable hashing and signature verification before setting the + // target. + backgroundFileSaver.enableSha256(); + backgroundFileSaver.enableSignatureInfo(); + if (partFilePath) { + // If we actually resumed a request, append to the partial data. + if (resumeAttempted) { + // TODO: Handle Cr.NS_ERROR_ENTITY_CHANGED + backgroundFileSaver.enableAppend(); + } + + // Use a part file, determining if we should keep it on failure. + backgroundFileSaver.setTarget( + new lazy.FileUtils.File(partFilePath), + keepPartialData + ); + } else { + // Set the final target file, and delete it on failure. + backgroundFileSaver.setTarget( + new lazy.FileUtils.File(targetPath), + false + ); + } + }.bind(this), + + onStopRequest(aRequest, aStatusCode) { + try { + backgroundFileSaver.onStopRequest(aRequest, aStatusCode); + } finally { + // If the data transfer completed successfully, indicate to the + // background file saver that the operation can finish. If the + // data transfer failed, the saver has been already stopped. + if (Components.isSuccessCode(aStatusCode)) { + backgroundFileSaver.finish(Cr.NS_OK); + } + } + }, + + onDataAvailable: (aRequest, aInputStream, aOffset, aCount) => { + // Check if the download have been canceled in the mean time, + // and close the channel and return earlier, BackgroundFileSaver + // methods shouldn't be called anymore after `finish` was called + // on download cancellation. + if (this._canceled) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + return; + } + backgroundFileSaver.onDataAvailable( + aRequest, + aInputStream, + aOffset, + aCount + ); + }, + }; + + // Wrap the channel creation, to prevent the listener code from + // accidentally using the wrong channel. + // The channel that is created here is not necessarily the same channel + // that will eventually perform the actual download. + // When a HTTP redirect happens, the http backend will create a new + // channel, this initial channel will be abandoned, and its properties + // will either return incorrect data, or worse, will throw exceptions + // upon access. + const open = async () => { + // Create a channel from the source, and listen to progress + // notifications. + let channel; + if (download.source.loadingPrincipal) { + channel = lazy.NetUtil.newChannel({ + uri: download.source.url, + contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, + loadingPrincipal: download.source.loadingPrincipal, + // triggeringPrincipal must be the system principal to prevent the + // request from being mistaken as a third-party request. + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + securityFlags: + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + } else { + channel = lazy.NetUtil.newChannel({ + uri: download.source.url, + contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, + loadUsingSystemPrincipal: true, + }); + } + if (channel instanceof Ci.nsIPrivateBrowsingChannel) { + channel.setPrivate(download.source.isPrivate); + } + if ( + channel instanceof Ci.nsIHttpChannel && + download.source.referrerInfo + ) { + channel.referrerInfo = download.source.referrerInfo; + // Stored computed referrerInfo; + download.source.referrerInfo = channel.referrerInfo; + } + if ( + channel instanceof Ci.nsIHttpChannel && + download.source.cookieJarSettings + ) { + channel.loadInfo.cookieJarSettings = + download.source.cookieJarSettings; + } + if ( + channel instanceof Ci.nsIHttpChannel && + download.source.authHeader + ) { + try { + channel.setRequestHeader( + "Authorization", + download.source.authHeader, + true + ); + } catch (e) {} + } + + if (download.source.userContextId) { + // Getters and setters only exist on originAttributes, + // so it has to be cloned, changed, and re-set + channel.loadInfo.originAttributes = { + ...channel.loadInfo.originAttributes, + userContextId: download.source.userContextId, + }; + } + + // This makes the channel be corretly throttled during page loads + // and also prevents its caching. + if (channel instanceof Ci.nsIHttpChannelInternal) { + channel.channelIsForDownload = true; + + // Include cookies even if cookieBehavior is BEHAVIOR_REJECT_FOREIGN. + channel.forceAllowThirdPartyCookie = true; + } + + if ( + channel instanceof Ci.nsIResumableChannel && + this.entityID && + partFilePath && + keepPartialData + ) { + try { + let stat = await IOUtils.stat(partFilePath); + channel.resumeAt(stat.size, this.entityID); + resumeAttempted = true; + resumeFromBytes = stat.size; + } catch (ex) { + if (ex.name != "NotFoundError") { + throw ex; + } + } + } + + channel.notificationCallbacks = notificationCallbacks; + + // If the callback was set, handle it now before opening the channel. + if (download.source.adjustChannel) { + await download.source.adjustChannel(channel); + } + channel.asyncOpen(streamListener); + }; + + // Kick off the download, creating and opening the channel. + await open(); + + // We should check if we have been canceled in the meantime, after + // all the previous asynchronous operations have been executed and + // just before we set the _backgroundFileSaver property. + if (this._canceled) { + throw new DownloadError({ message: "Saver canceled." }); + } + + // If the operation succeeded, store the object to allow cancellation. + this._backgroundFileSaver = backgroundFileSaver; + } catch (ex) { + // In case an error occurs while setting up the chain of objects for + // the download, ensure that we release the resources of the saver. + backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE); + // Since we're not going to handle deferSaveComplete.promise below, + // we need to make sure that the rejection is handled. + deferSaveComplete.promise.catch(() => {}); + throw ex; + } + + // We will wait on this promise in case no error occurred while setting + // up the chain of objects for the download. + await deferSaveComplete.promise; + + await this._checkReputationAndMove(aSetPropertiesFn); + }, + + /** + * 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. + * + * @param aSetPropertiesFn + * Function provided to the "execute" method. + * + * @return {Promise} + * @resolves When the reputation check and cleanup is complete. + * @rejects DownloadError if the download should be blocked. + */ + async _checkReputationAndMove(aSetPropertiesFn) { + let download = this.download; + let targetPath = this.download.target.path; + let partFilePath = this.download.target.partFilePath; + + let { shouldBlock, verdict } = + await lazy.DownloadIntegration.shouldBlockForReputationCheck(download); + if (shouldBlock) { + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(verdict, 0); + + let newProperties = { progress: 100, 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 (!lazy.DownloadIntegration.shouldKeepBlockedData() || !partFilePath) { + await this.removeData(!partFilePath); + } else { + newProperties.hasBlockedData = true; + } + + aSetPropertiesFn(newProperties); + + throw new DownloadError({ + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: verdict, + }); + } + + if (partFilePath) { + try { + await IOUtils.move(partFilePath, targetPath); + } catch (e) { + if (e.name === "NotAllowedError") { + // In case we cannot write to the target file + // retry with a new unique name + let uniquePath = lazy.DownloadPaths.createNiceUniqueFile( + new lazy.FileUtils.File(targetPath) + ).path; + await IOUtils.move(partFilePath, uniquePath); + this.download.target.path = uniquePath; + } else { + throw e; + } + } + } + }, + + /** + * Implements "DownloadSaver.cancel". + */ + cancel: function DCS_cancel() { + this._canceled = true; + if (this._backgroundFileSaver) { + this._backgroundFileSaver.finish(Cr.NS_ERROR_FAILURE); + this._backgroundFileSaver = null; + } + }, + + /** + * Implements "DownloadSaver.removeData". + */ + async removeData(canRemoveFinalTarget = false) { + // Defined inline so removeData can be shared with DownloadLegacySaver. + async function _tryToRemoveFile(path) { + try { + await IOUtils.remove(path); + } catch (ex) { + // On Windows we may get an access denied error instead of a no such + // file error if the file existed before, and was recently deleted. This + // is likely to happen when the component that executed the download has + // just deleted the target file itself. + if (!["NotFoundError", "NotAllowedError"].includes(ex.name)) { + console.error(ex); + } + } + } + + if (this.download.target.partFilePath) { + await _tryToRemoveFile(this.download.target.partFilePath); + } + + if (this.download.target.path) { + if ( + canRemoveFinalTarget || + (await isPlaceholder(this.download.target.path)) + ) { + await _tryToRemoveFile(this.download.target.path); + } + this.download.target.exists = false; + this.download.target.size = 0; + } + }, + + /** + * Implements "DownloadSaver.toSerializable". + */ + toSerializable() { + // Simplify the representation if we don't have other details. + if (!this.entityID && !this._unknownProperties) { + return "copy"; + } + + let serializable = { type: "copy", entityID: this.entityID }; + serializeUnknownProperties(this, serializable); + return serializable; + }, + + /** + * Implements "DownloadSaver.getSha256Hash" + */ + getSha256Hash() { + return this._sha256Hash; + }, + + /* + * Implements DownloadSaver.getSignatureInfo. + */ + getSignatureInfo() { + return this._signatureInfo; + }, + + /* + * Implements DownloadSaver.getRedirects. + */ + getRedirects() { + return this._redirects; + }, +}; +Object.setPrototypeOf(DownloadCopySaver.prototype, DownloadSaver.prototype); + +/** + * Creates a new DownloadCopySaver object, with its initial state derived from + * its serializable representation. + * + * @param aSerializable + * Serializable representation of a DownloadCopySaver object. + * + * @return The newly created DownloadCopySaver object. + */ +DownloadCopySaver.fromSerializable = function (aSerializable) { + let saver = new DownloadCopySaver(); + if ("entityID" in aSerializable) { + saver.entityID = aSerializable.entityID; + } + + deserializeUnknownProperties( + saver, + aSerializable, + property => property != "entityID" && property != "type" + ); + + return saver; +}; + +/** + * Saver object that integrates with the legacy nsITransfer interface. + * + * For more background on the process, see the DownloadLegacyTransfer object. + */ +export var DownloadLegacySaver = function () { + this.deferExecuted = Promise.withResolvers(); + this.deferCanceled = Promise.withResolvers(); +}; + +DownloadLegacySaver.prototype = { + /** + * Save the SHA-256 hash in raw bytes of the downloaded file. This may be + * null when nsExternalHelperAppService (and thus BackgroundFileSaver) is not + * invoked. + */ + _sha256Hash: null, + + /** + * Save the signature info as an Array of Array of raw bytes of nsIX509Cert + * if the file is signed. This is empty if the file is unsigned, and null + * unless BackgroundFileSaver has successfully completed saving the file. + */ + _signatureInfo: null, + + /** + * Save the redirect chain as an nsIArray of nsIPrincipal. + */ + _redirects: null, + + /** + * nsIRequest object associated to the status and progress updates we + * received. This object is null before we receive the first status and + * progress update, and is also reset to null when the download is stopped. + */ + request: null, + + /** + * This deferred object contains a promise that is resolved as soon as this + * download finishes successfully, and is rejected in case the download is + * canceled or receives a failure notification through nsITransfer. + */ + deferExecuted: null, + + /** + * This deferred object contains a promise that is resolved if the download + * receives a cancellation request through the "cancel" method, and is never + * rejected. The nsITransfer implementation will register a handler that + * actually causes the download cancellation. + */ + deferCanceled: null, + + /** + * This is populated with the value of the aSetProgressBytesFn argument of the + * "execute" method, and is null before the method is called. + */ + setProgressBytesFn: null, + + /** + * Called by the nsITransfer implementation while the download progresses. + * + * @param aCurrentBytes + * Number of bytes transferred until now. + * @param aTotalBytes + * Total number of bytes to be transferred, or -1 if unknown. + */ + onProgressBytes: function DLS_onProgressBytes(aCurrentBytes, aTotalBytes) { + this.progressWasNotified = true; + + // Ignore progress notifications until we are ready to process them. + if (!this.setProgressBytesFn) { + // Keep the data from the last progress notification that was received. + this.currentBytes = aCurrentBytes; + this.totalBytes = aTotalBytes; + return; + } + + let hasPartFile = !!this.download.target.partFilePath; + + this.setProgressBytesFn( + aCurrentBytes, + aTotalBytes, + aCurrentBytes > 0 && hasPartFile + ); + }, + + /** + * Whether the onProgressBytes function has been called at least once. + */ + progressWasNotified: false, + + /** + * Called by the nsITransfer implementation when the request has started. + * + * @param aRequest + * nsIRequest associated to the status update. + */ + onTransferStarted(aRequest) { + // Store a reference to the request, used in some cases when handling + // completion, and also checked during the download by unit tests. + this.request = aRequest; + + // Store the entity ID to use for resuming if required. + if ( + this.download.tryToKeepPartialData && + aRequest instanceof Ci.nsIResumableChannel + ) { + try { + // If reading the ID succeeds, the source is resumable. + this.entityID = aRequest.entityID; + } catch (ex) { + if ( + !(ex instanceof Components.Exception) || + ex.result != Cr.NS_ERROR_NOT_RESUMABLE + ) { + throw ex; + } + } + } + + // For legacy downloads, we must update the referrerInfo at this time. + if (aRequest instanceof Ci.nsIHttpChannel) { + this.download.source.referrerInfo = aRequest.referrerInfo; + } + + // Don't open the download panel when the user initiated to save a + // link or document. + if ( + aRequest instanceof Ci.nsIChannel && + aRequest.loadInfo.isUserTriggeredSave + ) { + this.download.openDownloadsListOnStart = false; + } + + this.addToHistory(); + }, + + /** + * Called by the nsITransfer implementation when the request has finished. + * + * @param aStatus + * Status code received by the nsITransfer implementation. + */ + onTransferFinished: function DLS_onTransferFinished(aStatus) { + if (Components.isSuccessCode(aStatus)) { + this.deferExecuted.resolve(); + } else { + // Infer the origin of the error from the failure code, because more + // specific data is not available through the nsITransfer implementation. + let properties = { result: aStatus, inferCause: true }; + this.deferExecuted.reject(new DownloadError(properties)); + } + }, + + /** + * When the first execution of the download finished, it can be restarted by + * using a DownloadCopySaver object instead of the original legacy component + * that executed the download. + */ + firstExecutionFinished: false, + + /** + * In case the download is restarted after the first execution finished, this + * property contains a reference to the DownloadCopySaver that is executing + * the new download attempt. + */ + copySaver: null, + + /** + * String corresponding to the entityID property of the nsIResumableChannel + * used to execute the download, or null if the channel was not resumable or + * the saver was instructed not to keep partially downloaded data. + */ + entityID: null, + + /** + * Implements "DownloadSaver.execute". + */ + async execute(aSetProgressBytesFn, aSetPropertiesFn) { + // Check if this is not the first execution of the download. The Download + // object guarantees that this function is not re-entered during execution. + if (this.firstExecutionFinished) { + if (!this.copySaver) { + this.copySaver = new DownloadCopySaver(); + this.copySaver.download = this.download; + this.copySaver.entityID = this.entityID; + this.copySaver.alreadyAddedToHistory = true; + } + await this.copySaver.execute.apply(this.copySaver, arguments); + return; + } + + this.setProgressBytesFn = aSetProgressBytesFn; + if (this.progressWasNotified) { + this.onProgressBytes(this.currentBytes, this.totalBytes); + } + + try { + // Wait for the component that executes the download to finish. + await this.deferExecuted.promise; + + // At this point, the "request" property has been populated. Ensure we + // report the value of "Content-Length", if available, even if the + // download didn't generate any progress events. + if ( + !this.progressWasNotified && + this.request instanceof Ci.nsIChannel && + this.request.contentLength >= 0 + ) { + aSetProgressBytesFn(0, this.request.contentLength); + } + + // If the component executing the download provides the path of a + // ".part" file, it means that it expects the listener to move the file + // 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. + // + // 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. + await IOUtils.writeUTF8(this.download.target.path, "", { + mode: "create", + }); + } catch (ex) { + if ( + !DOMException.isInstance(ex) || + ex.name !== "NoModificationAllowedError" + ) { + throw ex; + } + } + } + + await this._checkReputationAndMove(aSetPropertiesFn); + } catch (ex) { + // In case the operation failed, ensure we stop downloading data. Since + // we never re-enter this function, deferCanceled is always available. + this.deferCanceled.resolve(); + throw ex; + } finally { + // We don't need the reference to the request anymore. We must also set + // deferCanceled to null in order to free any indirect references it + // may hold to the request. + this.request = null; + this.deferCanceled = null; + // Allow the download to restart through a DownloadCopySaver. + this.firstExecutionFinished = true; + } + }, + + _checkReputationAndMove() { + return DownloadCopySaver.prototype._checkReputationAndMove.apply( + this, + arguments + ); + }, + + /** + * Implements "DownloadSaver.cancel". + */ + cancel: function DLS_cancel() { + // We may be using a DownloadCopySaver to handle resuming. + if (this.copySaver) { + return this.copySaver.cancel.apply(this.copySaver, arguments); + } + + // If the download hasn't stopped already, resolve deferCanceled so that the + // operation is canceled as soon as a cancellation handler is registered. + // Note that the handler might not have been registered yet. + if (this.deferCanceled) { + this.deferCanceled.resolve(); + } + }, + + /** + * Implements "DownloadSaver.removeData". + */ + removeData(canRemoveFinalTarget) { + // DownloadCopySaver and DownloadLegacySaver use the same logic for removing + // partially downloaded data, though this implementation isn't shared by + // other saver types, thus it isn't found on their shared prototype. + return DownloadCopySaver.prototype.removeData.call( + this, + canRemoveFinalTarget + ); + }, + + /** + * Implements "DownloadSaver.toSerializable". + */ + toSerializable() { + // This object depends on legacy components that are created externally, + // thus it cannot be rebuilt during deserialization. To support resuming + // across different browser sessions, this object is transformed into a + // DownloadCopySaver for the purpose of serialization. + return DownloadCopySaver.prototype.toSerializable.call(this); + }, + + /** + * Implements "DownloadSaver.getSha256Hash". + */ + getSha256Hash() { + if (this.copySaver) { + return this.copySaver.getSha256Hash(); + } + return this._sha256Hash; + }, + + /** + * Called by the nsITransfer implementation when the hash is available. + */ + setSha256Hash(hash) { + this._sha256Hash = hash; + }, + + /** + * Implements "DownloadSaver.getSignatureInfo". + */ + getSignatureInfo() { + if (this.copySaver) { + return this.copySaver.getSignatureInfo(); + } + return this._signatureInfo; + }, + + /** + * Called by the nsITransfer implementation when the hash is available. + */ + setSignatureInfo(signatureInfo) { + this._signatureInfo = signatureInfo; + }, + + /** + * Implements "DownloadSaver.getRedirects". + */ + getRedirects() { + if (this.copySaver) { + return this.copySaver.getRedirects(); + } + return this._redirects; + }, + + /** + * Called by the nsITransfer implementation when the redirect chain is + * available. + */ + setRedirects(redirects) { + this._redirects = redirects; + }, +}; +Object.setPrototypeOf(DownloadLegacySaver.prototype, DownloadSaver.prototype); + +/** + * Returns a new DownloadLegacySaver object. This saver type has a + * deserializable form only when creating a new object in memory, because it + * cannot be serialized to disk. + */ +DownloadLegacySaver.fromSerializable = function () { + return new DownloadLegacySaver(); +}; diff --git a/toolkit/components/downloads/DownloadHistory.sys.mjs b/toolkit/components/downloads/DownloadHistory.sys.mjs new file mode 100644 index 0000000000..0077601d84 --- /dev/null +++ b/toolkit/components/downloads/DownloadHistory.sys.mjs @@ -0,0 +1,863 @@ +/* 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/. */ + +/** + * Provides access to downloads from previous sessions on platforms that store + * them in a different location than session downloads. + * + * This module works with objects that are compatible with Download, while using + * the Places interfaces internally. Some of the Places objects may also be + * exposed to allow the consumers to integrate with history view commands. + */ + +import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +// Places query used to retrieve all history downloads for the related list. +const HISTORY_PLACES_QUERY = `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`; +const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI"; +const METADATA_ANNO = "downloads/metaData"; + +const METADATA_STATE_FINISHED = 1; +const METADATA_STATE_FAILED = 2; +const METADATA_STATE_CANCELED = 3; +const METADATA_STATE_PAUSED = 4; +const METADATA_STATE_BLOCKED_PARENTAL = 6; +const METADATA_STATE_DIRTY = 8; + +/** + * Provides methods to retrieve downloads from previous sessions and store + * downloads for future sessions. + */ +export let DownloadHistory = { + /** + * Retrieves the main DownloadHistoryList object which provides a unified view + * on downloads from both previous browsing sessions and this session. + * + * @param type + * Determines which type of downloads from this session should be + * included in the list. This is Downloads.PUBLIC by default, but can + * also be Downloads.PRIVATE or Downloads.ALL. + * @param maxHistoryResults + * Optional number that limits the amount of results the history query + * may return. + * + * @return {Promise} + * @resolves The requested DownloadHistoryList object. + * @rejects JavaScript exception. + */ + async getList({ type = lazy.Downloads.PUBLIC, maxHistoryResults } = {}) { + await DownloadCache.ensureInitialized(); + + let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`; + if (!this._listPromises[key]) { + this._listPromises[key] = lazy.Downloads.getList(type).then(list => { + // When the amount of history downloads is capped, we request the list in + // descending order, to make sure that the list can apply the limit. + let query = + HISTORY_PLACES_QUERY + + (maxHistoryResults ? `&maxResults=${maxHistoryResults}` : ""); + + return new DownloadHistoryList(list, query); + }); + } + + return this._listPromises[key]; + }, + + /** + * This object is populated with one key for each type of download list that + * can be returned by the getList method. The values are promises that resolve + * to DownloadHistoryList objects. + */ + _listPromises: {}, + + async addDownloadToHistory(download) { + if ( + download.source.isPrivate || + !lazy.PlacesUtils.history.canAddURI( + lazy.PlacesUtils.toURI(download.source.url) + ) + ) { + return; + } + + await DownloadCache.addDownload(download); + + await this._updateHistoryListData(download.source.url); + }, + + /** + * Stores new detailed metadata for the given download in history. This is + * normally called after a download finishes, fails, or is canceled. + * + * Failed or canceled downloads with partial data are not stored as paused, + * because the information from the session download is required for resuming. + * + * @param download + * Download object whose metadata should be updated. If the object + * represents a private download, the call has no effect. + */ + async updateMetaData(download) { + if ( + download.source.isPrivate || + !download.stopped || + !lazy.PlacesUtils.history.canAddURI( + lazy.PlacesUtils.toURI(download.source.url) + ) + ) { + return; + } + + let state = METADATA_STATE_CANCELED; + if (download.succeeded) { + state = METADATA_STATE_FINISHED; + } else if (download.error) { + if (download.error.becauseBlockedByParentalControls) { + state = METADATA_STATE_BLOCKED_PARENTAL; + } else if (download.error.becauseBlockedByReputationCheck) { + state = METADATA_STATE_DIRTY; + } else { + state = METADATA_STATE_FAILED; + } + } + + let metaData = { + state, + deleted: download.deleted, + endTime: download.endTime, + }; + if (download.succeeded) { + metaData.fileSize = download.target.size; + } + + // The verdict may still be present even if the download succeeded. + if (download.error && download.error.reputationCheckVerdict) { + metaData.reputationCheckVerdict = download.error.reputationCheckVerdict; + } + + // This should be executed before any async parts, to ensure the cache is + // updated before any notifications are activated. + await DownloadCache.setMetadata(download.source.url, metaData); + + await this._updateHistoryListData(download.source.url); + }, + + async _updateHistoryListData(sourceUrl) { + for (let key of Object.getOwnPropertyNames(this._listPromises)) { + let downloadHistoryList = await this._listPromises[key]; + downloadHistoryList.updateForMetaDataChange( + sourceUrl, + DownloadCache.get(sourceUrl) + ); + } + }, +}; + +/** + * This cache exists: + * - in order to optimize the load of DownloadsHistoryList, when Places + * annotations for history downloads must be read. In fact, annotations are + * stored in a single table, and reading all of them at once is much more + * efficient than an individual query. + * - to avoid needing to do asynchronous reading of the database during download + * list updates, which are designed to be synchronous (to improve UI + * responsiveness). + * + * The cache is initialized the first time DownloadHistory.getList is called, or + * when data is added. + */ +let DownloadCache = { + _data: new Map(), + _initializePromise: null, + + /** + * Initializes the cache, loading the data from the places database. + * + * @return {Promise} Returns a promise that is resolved once the + * initialization is complete. + */ + ensureInitialized() { + if (this._initializePromise) { + return this._initializePromise; + } + this._initializePromise = (async () => { + const placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["history-cleared", "page-removed"], + placesObserver + ); + + let pageAnnos = await lazy.PlacesUtils.history.fetchAnnotatedPages([ + METADATA_ANNO, + DESTINATIONFILEURI_ANNO, + ]); + + let metaDataPages = pageAnnos.get(METADATA_ANNO); + if (metaDataPages) { + for (let { uri, content } of metaDataPages) { + try { + this._data.set(uri.href, JSON.parse(content)); + } catch (ex) { + // Do nothing - JSON.parse could throw. + } + } + } + + let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO); + if (destinationFilePages) { + for (let { uri, content } of destinationFilePages) { + let newData = this.get(uri.href); + newData.targetFileSpec = content; + this._data.set(uri.href, newData); + } + } + })(); + + return this._initializePromise; + }, + + /** + * This returns an object containing the meta data for the supplied URL. + * + * @param {String} url The url to get the meta data for. + * @return {Object|null} Returns an empty object if there is no meta data found, or + * an object containing the meta data. The meta data + * will look like: + * + * { targetFileSpec, state, deleted, endTime, fileSize, ... } + * + * The targetFileSpec property is the value of "downloads/destinationFileURI", + * while the other properties are taken from "downloads/metaData". Any of the + * properties may be missing from the object. + */ + get(url) { + return this._data.get(url) || {}; + }, + + /** + * Adds a download to the cache and the places database. + * + * @param {Download} download The download to add to the database and cache. + */ + async addDownload(download) { + await this.ensureInitialized(); + + let targetFile = new lazy.FileUtils.File(download.target.path); + let targetUri = Services.io.newFileURI(targetFile); + + // This should be executed before any async parts, to ensure the cache is + // updated before any notifications are activated. + // Note: this intentionally overwrites any metadata as this is + // the start of a new download. + this._data.set(download.source.url, { targetFileSpec: targetUri.spec }); + + let originalPageInfo = await lazy.PlacesUtils.history.fetch( + download.source.url + ); + + let pageInfo = await lazy.PlacesUtils.history.insert({ + url: download.source.url, + // In case we are downloading a file that does not correspond to a web + // page for which the title is present, we populate the otherwise empty + // history title with the name of the destination file, to allow it to be + // visible and searchable in history results. + title: + (originalPageInfo && originalPageInfo.title) || targetFile.leafName, + visits: [ + { + // The start time is always available when we reach this point. + date: download.startTime, + transition: lazy.PlacesUtils.history.TRANSITIONS.DOWNLOAD, + referrer: download.source.referrerInfo + ? download.source.referrerInfo.originalReferrer + : null, + }, + ], + }); + + await lazy.PlacesUtils.history.update({ + annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]), + // XXX Bug 1479445: We shouldn't have to supply both guid and url here, + // but currently we do. + guid: pageInfo.guid, + url: pageInfo.url, + }); + }, + + /** + * Sets the metadata for a given url. If the cache already contains meta data + * for the given url, it will be overwritten (note: the targetFileSpec will be + * maintained). + * + * @param {String} url The url to set the meta data for. + * @param {Object} metadata The new metaData to save in the cache. + */ + async setMetadata(url, metadata) { + await this.ensureInitialized(); + + // This should be executed before any async parts, to ensure the cache is + // updated before any notifications are activated. + let existingData = this.get(url); + let newData = { ...metadata }; + if ("targetFileSpec" in existingData) { + newData.targetFileSpec = existingData.targetFileSpec; + } + this._data.set(url, newData); + + try { + await lazy.PlacesUtils.history.update({ + annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]), + url, + }); + } catch (ex) { + console.error(ex); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), + + handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "history-cleared": { + this._data.clear(); + break; + } + case "page-removed": { + if (event.isRemovedFromStore) { + this._data.delete(event.url); + } + break; + } + } + } + }, +}; + +/** + * Represents a download from the browser history. This object implements part + * of the interface of the Download object. + * + * While Download objects are shared between the public DownloadList and all the + * DownloadHistoryList instances, multiple HistoryDownload objects referring to + * the same item can be created for different DownloadHistoryList instances. + * + * @param placesNode + * The Places node from which the history download should be initialized. + */ +class HistoryDownload { + constructor(placesNode) { + this.placesNode = placesNode; + + // History downloads should get the referrer from Places (bug 829201). + this.source = { + url: placesNode.uri, + isPrivate: false, + }; + this.target = { + path: undefined, + exists: false, + size: undefined, + }; + + // In case this download cannot obtain its end time from the Places metadata, + // use the time from the Places node, that is the start time of the download. + this.endTime = placesNode.time / 1000; + } + + /** + * DownloadSlot containing this history download. + * + * @type {DownloadSlot} + */ + slot = null; + + /** + * History downloads are never in progress. + * + * @type {Boolean} + */ + stopped = true; + + /** + * No percentage indication is shown for history downloads. + * + * @type {Boolean} + */ + hasProgress = false; + + /** + * History downloads cannot be restarted using their partial data, even if + * they are indicated as paused in their Places metadata. The only way is to + * use the information from a persisted session download, that will be shown + * instead of the history download. In case this session download is not + * available, we show the history download as canceled, not paused. + * + * @type {Boolean} + */ + hasPartialData = false; + + /** + * Pushes information from Places metadata into this object. + */ + updateFromMetaData(metaData) { + try { + this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"] + .getService(Ci.nsIFileProtocolHandler) + .getFileFromURLSpec(metaData.targetFileSpec).path; + } catch (ex) { + this.target.path = undefined; + } + + if ("state" in metaData) { + this.succeeded = metaData.state == METADATA_STATE_FINISHED; + this.canceled = + metaData.state == METADATA_STATE_CANCELED || + metaData.state == METADATA_STATE_PAUSED; + this.endTime = metaData.endTime; + this.deleted = metaData.deleted; + + // Recreate partial error information from the state saved in history. + if (metaData.state == METADATA_STATE_FAILED) { + this.error = { message: "History download failed." }; + } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) { + this.error = { becauseBlockedByParentalControls: true }; + } else if (metaData.state == METADATA_STATE_DIRTY) { + this.error = { + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: metaData.reputationCheckVerdict || "", + }; + } else { + this.error = null; + } + + // Normal history downloads are assumed to exist until the user interface + // is refreshed, at which point these values may be updated. + this.target.exists = true; + this.target.size = metaData.fileSize; + } else { + // Metadata might be missing from a download that has started but hasn't + // stopped already. Normally, this state is overridden with the one from + // the corresponding in-progress session download. But if the browser is + // terminated abruptly and additionally the file with information about + // in-progress downloads is lost, we may end up using this state. We use + // the failed state to allow the download to be restarted. + // + // On the other hand, if the download is missing the target file + // annotation as well, it is just a very old one, and we can assume it + // succeeded. + this.succeeded = !this.target.path; + this.error = this.target.path ? { message: "Unstarted download." } : null; + this.canceled = false; + this.deleted = false; + + // These properties may be updated if the user interface is refreshed. + this.target.exists = false; + this.target.size = undefined; + } + } + + /** + * This method may be called when deleting a history download. + */ + async finalize() {} + + /** + * This method mimicks the "refresh" method of session downloads. + */ + async refresh() { + try { + this.target.size = (await IOUtils.stat(this.target.path)).size; + this.target.exists = true; + } catch (ex) { + // We keep the known file size from the metadata, if any. + this.target.exists = false; + } + + this.slot.list._notifyAllViews("onDownloadChanged", this); + } + + /** + * This method mimicks the "manuallyRemoveData" method of session downloads. + */ + async manuallyRemoveData() { + let { path } = this.target; + if (this.target.path && this.succeeded) { + // Temp files are made "read-only" by DownloadIntegration.downloadDone, so + // reset the permission bits to read/write. This won't be necessary after + // bug 1733587 since Downloads won't ever be temporary. + await IOUtils.setPermissions(path, 0o660); + await IOUtils.remove(path, { ignoreAbsent: true }); + } + this.deleted = true; + await this.refresh(); + } +} + +/** + * Represents one item in the list of public session and history downloads. + * + * The object may contain a session download, a history download, or both. When + * both a history and a session download are present, the session download gets + * priority and its information is accessed. + * + * @param list + * The DownloadHistoryList that owns this DownloadSlot object. + */ +class DownloadSlot { + constructor(list) { + this.list = list; + } + + /** + * Download object representing the session download contained in this slot. + */ + sessionDownload = null; + _historyDownload = null; + + /** + * HistoryDownload object contained in this slot. + */ + get historyDownload() { + return this._historyDownload; + } + + set historyDownload(historyDownload) { + this._historyDownload = historyDownload; + if (historyDownload) { + historyDownload.slot = this; + } + } + + /** + * Returns the Download or HistoryDownload object for displaying information + * and executing commands in the user interface. + */ + get download() { + return this.sessionDownload || this.historyDownload; + } +} + +/** + * Represents an ordered collection of DownloadSlot objects containing a merged + * view on session downloads and history downloads. Views on this list will + * receive notifications for changes to both types of downloads. + * + * Downloads in this list are sorted from oldest to newest, with all session + * downloads after all the history downloads. When a new history download is + * added and the list also contains session downloads, the insertBefore option + * of the onDownloadAdded notification refers to the first session download. + * + * The list of downloads cannot be modified using the DownloadList methods. + * + * @param publicList + * Underlying DownloadList containing public downloads. + * @param place + * Places query used to retrieve history downloads. + */ +class DownloadHistoryList extends DownloadList { + constructor(publicList, place) { + super(); + + // While "this._slots" contains all the data in order, the other properties + // provide fast access for the most common operations. + this._slots = []; + this._slotsForUrl = new Map(); + this._slotForDownload = new WeakMap(); + + // Start the asynchronous queries to retrieve history and session downloads. + publicList.addView(this).catch(console.error); + let query = {}, + options = {}; + lazy.PlacesUtils.history.queryStringToQuery(place, query, options); + + // NB: The addObserver call sets our nsINavHistoryResultObserver.result. + let result = lazy.PlacesUtils.history.executeQuery( + query.value, + options.value + ); + result.addObserver(this); + + // Our history result observer is long lived for fast shared views, so free + // the reference on shutdown to prevent leaks. + Services.obs.addObserver(() => { + this.result = null; + }, "quit-application-granted"); + } + + /** + * This is set when executing the Places query. + */ + _result = null; + + /** + * Index of the first slot that contains a session download. This is equal to + * the length of the list when there are no session downloads. + * + * @type {Number} + */ + _firstSessionSlotIndex = 0; + + get result() { + return this._result; + } + + set result(result) { + if (this._result == result) { + return; + } + + if (this._result) { + this._result.removeObserver(this); + this._result.root.containerOpen = false; + } + + this._result = result; + + if (this._result) { + this._result.root.containerOpen = true; + } + } + + /** + * Updates the download history item when the meta data or destination file + * changes. + * + * @param {String} sourceUrl The sourceUrl which was updated. + * @param {Object} metaData The new meta data for the sourceUrl. + */ + updateForMetaDataChange(sourceUrl, metaData) { + let slotsForUrl = this._slotsForUrl.get(sourceUrl); + if (!slotsForUrl) { + return; + } + + for (let slot of slotsForUrl) { + if (slot.sessionDownload) { + // The visible data doesn't change, so we don't have to notify views. + return; + } + slot.historyDownload.updateFromMetaData(metaData); + this._notifyAllViews("onDownloadChanged", slot.download); + } + } + + _insertSlot({ slot, index, slotsForUrl }) { + // Add the slot to the ordered array. + this._slots.splice(index, 0, slot); + this._downloads.splice(index, 0, slot.download); + if (!slot.sessionDownload) { + this._firstSessionSlotIndex++; + } + + // Add the slot to the fast access maps. + slotsForUrl.add(slot); + this._slotsForUrl.set(slot.download.source.url, slotsForUrl); + + // Add the associated view items. + this._notifyAllViews("onDownloadAdded", slot.download, { + insertBefore: this._downloads[index + 1], + }); + } + + _removeSlot({ slot, slotsForUrl }) { + // Remove the slot from the ordered array. + let index = this._slots.indexOf(slot); + this._slots.splice(index, 1); + this._downloads.splice(index, 1); + if (this._firstSessionSlotIndex > index) { + this._firstSessionSlotIndex--; + } + + // Remove the slot from the fast access maps. + slotsForUrl.delete(slot); + if (slotsForUrl.size == 0) { + this._slotsForUrl.delete(slot.download.source.url); + } + + // Remove the associated view items. + this._notifyAllViews("onDownloadRemoved", slot.download); + } + + /** + * Ensures that the information about a history download is stored in at least + * one slot, adding a new one at the end of the list if necessary. + * + * A reference to the same Places node will be stored in the HistoryDownload + * object for all the DownloadSlot objects associated with the source URL. + * + * @param placesNode + * The Places node that represents the history download. + */ + _insertPlacesNode(placesNode) { + let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set(); + + // If there are existing slots associated with this URL, we only have to + // ensure that the Places node reference is kept updated in case the more + // recent Places notification contained a different node object. + if (slotsForUrl.size > 0) { + for (let slot of slotsForUrl) { + if (!slot.historyDownload) { + slot.historyDownload = new HistoryDownload(placesNode); + } else { + slot.historyDownload.placesNode = placesNode; + } + } + return; + } + + // If there are no existing slots for this URL, we have to create a new one. + // Since the history download is visible in the slot, we also have to update + // the object using the Places metadata. + let historyDownload = new HistoryDownload(placesNode); + historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri)); + let slot = new DownloadSlot(this); + slot.historyDownload = historyDownload; + this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex }); + } + + // nsINavHistoryResultObserver + containerStateChanged(node, oldState, newState) { + this.invalidateContainer(node); + } + + // nsINavHistoryResultObserver + invalidateContainer(container) { + this._notifyAllViews("onDownloadBatchStarting"); + + // Remove all the current slots containing only history downloads. + for (let index = this._slots.length - 1; index >= 0; index--) { + let slot = this._slots[index]; + if (slot.sessionDownload) { + // The visible data doesn't change, so we don't have to notify views. + slot.historyDownload = null; + } else { + let slotsForUrl = this._slotsForUrl.get(slot.download.source.url); + this._removeSlot({ slot, slotsForUrl }); + } + } + + // Add new slots or reuse existing ones for history downloads. + for (let index = container.childCount - 1; index >= 0; --index) { + try { + this._insertPlacesNode(container.getChild(index)); + } catch (ex) { + console.error(ex); + } + } + + this._notifyAllViews("onDownloadBatchEnded"); + } + + // nsINavHistoryResultObserver + nodeInserted(parent, placesNode) { + this._insertPlacesNode(placesNode); + } + + // nsINavHistoryResultObserver + nodeRemoved(parent, placesNode, aOldIndex) { + let slotsForUrl = this._slotsForUrl.get(placesNode.uri); + for (let slot of slotsForUrl) { + if (slot.sessionDownload) { + // The visible data doesn't change, so we don't have to notify views. + slot.historyDownload = null; + } else { + this._removeSlot({ slot, slotsForUrl }); + } + } + } + + // nsINavHistoryResultObserver + nodeIconChanged() {} + nodeTitleChanged() {} + nodeKeywordChanged() {} + nodeDateAddedChanged() {} + nodeLastModifiedChanged() {} + nodeHistoryDetailsChanged() {} + nodeTagsChanged() {} + sortingChanged() {} + nodeMoved() {} + nodeURIChanged() {} + batching() {} + + // DownloadList callback + onDownloadAdded(download) { + let url = download.source.url; + let slotsForUrl = this._slotsForUrl.get(url) || new Set(); + + // For every source URL, there can be at most one slot containing a history + // download without an associated session download. If we find one, then we + // can reuse it for the current session download, although we have to move + // it together with the other session downloads. + let slot = [...slotsForUrl][0]; + if (slot && !slot.sessionDownload) { + // Remove the slot because we have to change its position. + this._removeSlot({ slot, slotsForUrl }); + } else { + slot = new DownloadSlot(this); + } + slot.sessionDownload = download; + this._insertSlot({ slot, slotsForUrl, index: this._slots.length }); + this._slotForDownload.set(download, slot); + } + + // DownloadList callback + onDownloadChanged(download) { + let slot = this._slotForDownload.get(download); + this._notifyAllViews("onDownloadChanged", slot.download); + } + + // DownloadList callback + onDownloadRemoved(download) { + let url = download.source.url; + let slotsForUrl = this._slotsForUrl.get(url); + let slot = this._slotForDownload.get(download); + this._removeSlot({ slot, slotsForUrl }); + + this._slotForDownload.delete(download); + + // If there was only one slot for this source URL and it also contained a + // history download, we should resurrect it in the correct area of the list. + if (slotsForUrl.size == 0 && slot.historyDownload) { + // We have one download slot containing both a session download and a + // history download, and we are now removing the session download. + // Previously, we did not use the Places metadata because it was obscured + // by the session download. Since this is no longer the case, we have to + // read the latest metadata before resurrecting the history download. + slot.historyDownload.updateFromMetaData(DownloadCache.get(url)); + slot.sessionDownload = null; + // Place the resurrected history slot after all the session slots. + this._insertSlot({ + slot, + slotsForUrl, + index: this._firstSessionSlotIndex, + }); + } + } + + // DownloadList + add() { + throw new Error("Not implemented."); + } + + // DownloadList + remove() { + throw new Error("Not implemented."); + } + + // DownloadList + removeFinished() { + throw new Error("Not implemented."); + } +} diff --git a/toolkit/components/downloads/DownloadIntegration.sys.mjs b/toolkit/components/downloads/DownloadIntegration.sys.mjs new file mode 100644 index 0000000000..5762f95cc2 --- /dev/null +++ b/toolkit/components/downloads/DownloadIntegration.sys.mjs @@ -0,0 +1,1327 @@ +/* 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/. */ + +/** + * Provides functions to integrate with the host application, handling for + * example the global prompts on shutdown. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { Downloads } from "resource://gre/modules/Downloads.sys.mjs"; +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + DownloadSpamProtection: "resource:///modules/DownloadSpamProtection.sys.mjs", + DownloadStore: "resource://gre/modules/DownloadStore.sys.mjs", + DownloadUIHelper: "resource://gre/modules/DownloadUIHelper.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gDownloadPlatform", + "@mozilla.org/toolkit/download-platform;1", + "mozIDownloadPlatform" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gExternalProtocolService", + "@mozilla.org/uriloader/external-protocol-service;1", + "nsIExternalProtocolService" +); + +ChromeUtils.defineLazyGetter(lazy, "gParentalControlsService", function () { + if ("@mozilla.org/parental-controls-service;1" in Cc) { + return Cc["@mozilla.org/parental-controls-service;1"].createInstance( + Ci.nsIParentalControlsService + ); + } + return null; +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gApplicationReputationService", + "@mozilla.org/reputationservice/application-reputation-service;1", + Ci.nsIApplicationReputationService +); + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); +ChromeUtils.defineLazyGetter(lazy, "gCombinedDownloadIntegration", () => { + return lazy.DownloadIntegration; +}); + +ChromeUtils.defineLazyGetter(lazy, "stringBundle", () => + Services.strings.createBundle( + "chrome://mozapps/locale/downloads/downloads.properties" + ) +); + +const Timer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +/** + * Indicates the delay between a change to the downloads data and the related + * save operation. + * + * For best efficiency, this value should be high enough that the input/output + * for opening or closing the target file does not overlap with the one for + * saving the list of downloads. + */ +const kSaveDelayMs = 1500; + +/** + * List of observers to listen against + */ +const kObserverTopics = [ + "quit-application-requested", + "offline-requested", + "last-pb-context-exiting", + "last-pb-context-exited", + "sleep_notification", + "suspend_process_notification", + "wake_notification", + "resume_process_notification", + "network:offline-about-to-go-offline", + "network:offline-status-changed", + "xpcom-will-shutdown", + "blocked-automatic-download", +]; + +/** + * Maps nsIApplicationReputationService verdicts with the DownloadError ones. + */ +const kVerdictMap = { + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS]: + Downloads.Error.BLOCK_VERDICT_MALWARE, + [Ci.nsIApplicationReputationService.VERDICT_UNCOMMON]: + Downloads.Error.BLOCK_VERDICT_UNCOMMON, + [Ci.nsIApplicationReputationService.VERDICT_POTENTIALLY_UNWANTED]: + Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED, + [Ci.nsIApplicationReputationService.VERDICT_DANGEROUS_HOST]: + Downloads.Error.BLOCK_VERDICT_MALWARE, +}; + +/** + * Provides functions to integrate with the host application, handling for + * example the global prompts on shutdown. + */ +export var DownloadIntegration = { + /** + * Main DownloadStore object for loading and saving the list of persistent + * downloads, or null if the download list was never requested and thus it + * doesn't need to be persisted. + */ + _store: null, + + /** + * 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() { + const FIREFOX_ID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; + return Services.appinfo.ID == FIREFOX_ID; + }, + + /** + * Performs initialization of the list of persistent downloads, before its + * first use by the host application. This function may be called only once + * during the entire lifetime of the application. + * + * @param list + * DownloadList object to be initialized. + * + * @return {Promise} + * @resolves When the list has been initialized. + * @rejects JavaScript exception. + */ + async initializePublicDownloadList(list) { + try { + await this.loadPublicDownloadListFromStore(list); + } catch (ex) { + console.error(ex); + } + + if (AppConstants.MOZ_PLACES) { + // After the list of persistent downloads has been loaded, we can add the + // history observers, even if the load operation failed. This object is kept + // alive by the history service. + new DownloadHistoryObserver(list); + } + }, + + /** + * Called by initializePublicDownloadList to load the list of persistent + * downloads, before its first use by the host application. This function may + * be called only once during the entire lifetime of the application. + * + * @param list + * DownloadList object to be populated with the download objects + * serialized from the previous session. This list will be persisted + * to disk during the session lifetime. + * + * @return {Promise} + * @resolves When the list has been populated. + * @rejects JavaScript exception. + */ + async loadPublicDownloadListFromStore(list) { + if (this._store) { + throw new Error("Initialization may be performed only once."); + } + + this._store = new lazy.DownloadStore( + list, + PathUtils.join(PathUtils.profileDir, "downloads.json") + ); + this._store.onsaveitem = this.shouldPersistDownload.bind(this); + + try { + await this._store.load(); + } catch (ex) { + console.error(ex); + } + + // Add the view used for detecting changes to downloads to be persisted. + // We must do this after the list of persistent downloads has been loaded, + // even if the load operation failed. We wait for a complete initialization + // so other callers cannot modify the list without being detected. The + // DownloadAutoSaveView is kept alive by the underlying DownloadList. + await new DownloadAutoSaveView(list, this._store).initialize(); + }, + + /** + * Determines if a Download object from the list of persistent downloads + * should be saved into a file, so that it can be restored across sessions. + * + * This function allows filtering out downloads that the host application is + * not interested in persisting across sessions, for example downloads that + * finished successfully. + * + * @param aDownload + * The Download object to be inspected. This is originally taken from + * the global DownloadList object for downloads that were not started + * from a private browsing window. The item may have been removed + * from the list since the save operation started, though in this case + * the save operation will be repeated later. + * + * @return True to save the download, false otherwise. + */ + shouldPersistDownload(aDownload) { + // On all platforms, we save all the downloads currently in progress, as + // well as stopped downloads for which we retained partially downloaded + // data or we have blocked data. + // On Android we store all history; on Desktop, stopped downloads for which + // we don't need to track the presence of a ".part" file are only retained + // in the browser history. + return ( + !aDownload.stopped || + aDownload.hasPartialData || + aDownload.hasBlockedData || + AppConstants.platform == "android" + ); + }, + + /** + * Returns the system downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + async getSystemDownloadsDirectory() { + if (this._downloadsDirectory) { + return this._downloadsDirectory; + } + + if (AppConstants.platform == "android") { + // Android doesn't have a $HOME directory, and by default we only have + // write access to /data/data/org.mozilla.{$APP} and /sdcard + this._downloadsDirectory = Services.env.get("DOWNLOADS_DIRECTORY"); + if (!this._downloadsDirectory) { + throw new Components.Exception( + "DOWNLOADS_DIRECTORY is not set.", + Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH + ); + } + } else { + try { + this._downloadsDirectory = this._getDirectory("DfltDwnld"); + } catch (e) { + this._downloadsDirectory = await this._createDownloadsDirectory("Home"); + } + } + + return this._downloadsDirectory; + }, + _downloadsDirectory: null, + + /** + * Returns the user downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + async getPreferredDownloadsDirectory() { + let directoryPath = null; + let prefValue = Services.prefs.getIntPref("browser.download.folderList", 1); + + switch (prefValue) { + case 0: // Desktop + directoryPath = this._getDirectory("Desk"); + break; + case 1: // Downloads + directoryPath = await this.getSystemDownloadsDirectory(); + break; + case 2: // Custom + try { + let directory = Services.prefs.getComplexValue( + "browser.download.dir", + Ci.nsIFile + ); + directoryPath = directory.path; + await IOUtils.makeDirectory(directoryPath, { + createAncestors: false, + }); + } catch (ex) { + console.error(ex); + // Either the preference isn't set or the directory cannot be created. + directoryPath = await this.getSystemDownloadsDirectory(); + } + break; + default: + directoryPath = await this.getSystemDownloadsDirectory(); + } + return directoryPath; + }, + + /** + * Returns the temporary downloads directory asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + async getTemporaryDownloadsDirectory() { + let directoryPath = null; + if (AppConstants.platform == "macosx") { + directoryPath = await this.getPreferredDownloadsDirectory(); + } else if (AppConstants.platform == "android") { + directoryPath = await this.getSystemDownloadsDirectory(); + } else { + directoryPath = this._getDirectory("TmpD"); + } + return directoryPath; + }, + + /** + * Checks to determine whether to block downloads for parental controls. + * + * aParam aDownload + * The download object. + * + * @return {Promise} + * @resolves The boolean indicates to block downloads or not. + */ + shouldBlockForParentalControls(aDownload) { + let isEnabled = + lazy.gParentalControlsService && + lazy.gParentalControlsService.parentalControlsEnabled; + let shouldBlock = + isEnabled && lazy.gParentalControlsService.blockFileDownloadsEnabled; + + // Log the event if required by parental controls settings. + if (isEnabled && lazy.gParentalControlsService.loggingEnabled) { + lazy.gParentalControlsService.log( + lazy.gParentalControlsService.ePCLog_FileDownload, + shouldBlock, + lazy.NetUtil.newURI(aDownload.source.url), + null + ); + } + + return Promise.resolve(shouldBlock); + }, + + /** + * Checks to determine whether to block downloads because they might be + * malware, based on application reputation checks. + * + * aParam aDownload + * The download object. + * + * @return {Promise} + * @resolves Object with the following properties: + * { + * shouldBlock: Whether the download should be blocked. + * verdict: Detailed reason for the block, according to the + * "Downloads.Error.BLOCK_VERDICT_" constants, or empty + * string if the reason is unknown. + * } + */ + shouldBlockForReputationCheck(aDownload) { + let hash; + let sigInfo; + let channelRedirects; + try { + hash = aDownload.saver.getSha256Hash(); + sigInfo = aDownload.saver.getSignatureInfo(); + channelRedirects = aDownload.saver.getRedirects(); + } catch (ex) { + // Bail if DownloadSaver doesn't have a hash or signature info. + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + } + if (!hash || !sigInfo) { + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + } + return new Promise(resolve => { + lazy.gApplicationReputationService.queryReputation( + { + sourceURI: lazy.NetUtil.newURI(aDownload.source.url), + referrerInfo: aDownload.source.referrerInfo, + fileSize: aDownload.currentBytes, + sha256Hash: hash, + suggestedFileName: PathUtils.filename(aDownload.target.path), + signatureInfo: sigInfo, + redirects: channelRedirects, + }, + function onComplete(aShouldBlock, aRv, aVerdict) { + resolve({ + shouldBlock: aShouldBlock, + verdict: (aShouldBlock && kVerdictMap[aVerdict]) || "", + }); + } + ); + }); + }, + + /** + * Checks whether downloaded files should be marked as coming from + * Internet Zone. + * + * @return true if files should be marked + */ + _shouldSaveZoneInformation() { + let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + try { + key.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\Attachments", + Ci.nsIWindowsRegKey.ACCESS_QUERY_VALUE + ); + try { + return key.readIntValue("SaveZoneInformation") != 1; + } finally { + key.close(); + } + } catch (ex) { + // If the key is not present, files should be marked by default. + return true; + } + }, + + /** + * Builds a key and URL value pair for the "Zone.Identifier" Alternate Data + * Stream. + * + * @param aKey + * String to write before the "=" sign. This is not validated. + * @param aUrl + * URL string to write after the "=" sign. Only the "http(s)" and + * "ftp" schemes are allowed, and usernames and passwords are + * stripped. + * @param [optional] aFallback + * Value to place after the "=" sign in case the URL scheme is not + * allowed. If unspecified, an empty string is returned when the + * scheme is not allowed. + * + * @return Line to add to the stream, including the final CRLF, or an empty + * string if the validation failed. + */ + _zoneIdKey(aKey, aUrl, aFallback) { + try { + let url; + const uri = lazy.NetUtil.newURI(aUrl); + if (["http", "https", "ftp"].includes(uri.scheme)) { + url = uri.mutate().setUserPass("").finalize().spec; + } else if (aFallback) { + url = aFallback; + } else { + return ""; + } + return aKey + "=" + url + "\r\n"; + } catch (e) { + return ""; + } + }, + + /** + * Performs platform-specific operations when a download is done. + * + * aParam aDownload + * The Download object. + * + * @return {Promise} + * @resolves When all the operations completed successfully. + * @rejects JavaScript exception if any of the operations failed. + */ + async downloadDone(aDownload) { + // On Windows, we mark any file saved to the NTFS file system as coming + // from the Internet security zone unless Group Policy disables the + // feature. We do this by writing to the "Zone.Identifier" Alternate + // Data Stream directly, because the Save method of the + // IAttachmentExecute interface would trigger operations that may cause + // the application to hang, or other performance issues. + // The stream created in this way is forward-compatible with all the + // current and future versions of Windows. + if (AppConstants.platform == "win" && this._shouldSaveZoneInformation()) { + let zone; + try { + zone = lazy.gDownloadPlatform.mapUrlToZone(aDownload.source.url); + } catch (e) { + // Default to Internet Zone if mapUrlToZone failed for + // whatever reason. + zone = Ci.mozIDownloadPlatform.ZONE_INTERNET; + } + // Don't write zone IDs for Local, Intranet, or Trusted sites + // to match Windows behavior. + if (zone >= Ci.mozIDownloadPlatform.ZONE_INTERNET) { + let path = aDownload.target.path + ":Zone.Identifier"; + try { + let zoneId = "[ZoneTransfer]\r\nZoneId=" + zone + "\r\n"; + let { url, isPrivate, referrerInfo } = aDownload.source; + if (!isPrivate) { + let referrer = referrerInfo + ? referrerInfo.computedReferrerSpec + : ""; + zoneId += + this._zoneIdKey("ReferrerUrl", referrer) + + this._zoneIdKey("HostUrl", url, "about:internet"); + } + await IOUtils.writeUTF8( + PathUtils.toExtendedWindowsPath(path), + zoneId + ); + } catch (ex) { + // If writing to the file fails, we ignore the error and continue. + if (!DOMException.isInstance(ex)) { + console.error(ex); + } + } + } + } + + // The file with the partially downloaded data has restrictive permissions + // that don't allow other users on the system to access it. Now that the + // download is completed, we need to adjust permissions based on whether + // this is a permanently downloaded file or a temporary download to be + // opened read-only with an external application. + try { + let isTemporaryDownload = + aDownload.launchWhenSucceeded && aDownload.source.isPrivate; + // Permanently downloaded files are made accessible by other users on + // this system, while temporary downloads are marked as read-only. + let unixMode; + if (isTemporaryDownload) { + unixMode = 0o400; + } else { + unixMode = 0o666; + } + // On Unix, the umask of the process is respected. + await IOUtils.setPermissions(aDownload.target.path, unixMode); + } catch (ex) { + // We should report errors with making the permissions less restrictive + // or marking the file as read-only on Unix and Mac, but this should not + // prevent the download from completing. + if (!DOMException.isInstance(ex)) { + console.error(ex); + } + } + + let aReferrer = null; + if (aDownload.source.referrerInfo) { + aReferrer = aDownload.source.referrerInfo.originalReferrer; + } + + await lazy.gDownloadPlatform.downloadDone( + lazy.NetUtil.newURI(aDownload.source.url), + aReferrer, + new lazy.FileUtils.File(aDownload.target.path), + aDownload.contentType, + aDownload.source.isPrivate + ); + }, + + /** + * Decide whether a download of this type, opened from the downloads + * list, should open internally. + * + * @param aMimeType + * The MIME type of the file, as a string + * @param [optional] aExtension + * The file extension, which can match instead of the MIME type. + */ + shouldViewDownloadInternally(aMimeType, aExtension) { + // Refuse all files by default, this is meant to be replaced with a check + // for specific types via Integration.downloads.register(). + return false; + }, + + /** + * Launches a file represented by the target of a download. This can + * open the file with the default application for the target MIME type + * or file extension, or with a custom application if + * aDownload.launcherPath is set. + * + * @param aDownload + * A Download object that contains the necessary information + * to launch the file. The relevant properties are: the target + * file, the contentType and the custom application chosen + * to launch it. + * @param options.openWhere Optional string indicating how to open when handling + * download by opening the target file URI. + * One of "window", "tab", "tabshifted" + * @param options.useSystemDefault + * Optional value indicating how to handle launching this download, + * this time only. Will override the associated mimeInfo.preferredAction + * + * @return {Promise} + * @resolves When the instruction to launch the file has been + * successfully given to the operating system. Note that + * the OS might still take a while until the file is actually + * launched. + * @rejects JavaScript exception if there was an error trying to launch + * the file. + */ + async launchDownload(aDownload, { openWhere, useSystemDefault = null }) { + let file = new lazy.FileUtils.File(aDownload.target.path); + + // In case of a double extension, like ".tar.gz", we only + // consider the last one, because the MIME service cannot + // handle multiple extensions. + let fileExtension = null, + mimeInfo = null; + let match = file.leafName.match(/\.([^.]+)$/); + if (match) { + fileExtension = match[1]; + } + + let isWindowsExe = + AppConstants.platform == "win" && + fileExtension && + fileExtension.toLowerCase() == "exe"; + + let isExemptExecutableExtension = + Services.policies.isExemptExecutableExtension( + aDownload.source.url, + fileExtension + ); + + // Ask for confirmation if the file is executable, except for .exe on + // Windows where the operating system will show the prompt based on the + // security zone. We do this here, instead of letting the caller handle + // the prompt separately in the user interface layer, for two reasons. The + // first is because of its security nature, so that add-ons cannot forget + // to do this check. The second is that the system-level security prompt + // would be displayed at launch time in any case. + // We allow policy to override this behavior for file extensions on specific domains. + if ( + file.isExecutable() && + !isWindowsExe && + !isExemptExecutableExtension && + !(await this.confirmLaunchExecutable(file.path)) + ) { + return; + } + + try { + // The MIME service might throw if contentType == "" and it can't find + // a MIME type for the given extension, so we'll treat this case as + // an unknown mimetype. + mimeInfo = lazy.gMIMEService.getFromTypeAndExtension( + aDownload.contentType, + fileExtension + ); + } catch (e) {} + + if (aDownload.launcherPath) { + if (!mimeInfo) { + // This should not happen on normal circumstances because launcherPath + // is only set when we had an instance of nsIMIMEInfo to retrieve + // the custom application chosen by the user. + throw new Error( + "Unable to create nsIMIMEInfo to launch a custom application" + ); + } + + // Custom application chosen + let localHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandlerApp.executable = new lazy.FileUtils.File( + aDownload.launcherPath + ); + + mimeInfo.preferredApplicationHandler = localHandlerApp; + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + + this.launchFile(file, mimeInfo); + // After an attempt has been made to launch the download, clear the + // launchWhenSucceeded bit so future attempts to open the download can go + // through Firefox when possible. + aDownload.launchWhenSucceeded = false; + return; + } + + if (!useSystemDefault && mimeInfo) { + useSystemDefault = mimeInfo.preferredAction == mimeInfo.useSystemDefault; + } + if (!useSystemDefault) { + // No explicit instruction was passed to launch this download using the default system viewer. + if ( + aDownload.handleInternally || + (mimeInfo && + this.shouldViewDownloadInternally(mimeInfo.type, fileExtension) && + !mimeInfo.alwaysAskBeforeHandling && + (mimeInfo.preferredAction === Ci.nsIHandlerInfo.handleInternally || + (["image/svg+xml", "text/xml", "application/xml"].includes( + mimeInfo.type + ) && + mimeInfo.preferredAction === Ci.nsIHandlerInfo.saveToDisk)) && + !aDownload.launchWhenSucceeded) + ) { + lazy.DownloadUIHelper.loadFileIn(file, { + browsingContextId: aDownload.source.browsingContextId, + isPrivate: aDownload.source.isPrivate, + openWhere, + userContextId: aDownload.source.userContextId, + }); + return; + } + } + + // An attempt will now be made to launch the download, clear the + // launchWhenSucceeded bit so future attempts to open the download can go + // through Firefox when possible. + aDownload.launchWhenSucceeded = false; + + // When a file has no extension, and there's an executable file with the + // same name in the same folder, Windows shell can get confused. + // For this reason we show the file in the containing folder instead of + // trying to open it. + // We also don't trust mimeinfo, it could be a type we can forward to a + // system handler, but it could also be an executable type, and we + // don't have an exhaustive list with all of them. + if (!fileExtension && AppConstants.platform == "win") { + // We can't check for the existance of a same-name file with every + // possible executable extension, so this is a catch-all. + this.showContainingDirectory(aDownload.target.path); + return; + } + + // No custom application chosen, let's launch the file with the default + // handler. First, let's try to launch it through the MIME service. + if (mimeInfo) { + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useSystemDefault; + try { + this.launchFile(file, mimeInfo); + return; + } catch (ex) {} + } + + // If it didn't work or if there was no MIME info available, + // let's try to directly launch the file. + try { + this.launchFile(file); + return; + } catch (ex) {} + + // If our previous attempts failed, try sending it through + // the system's external "file:" URL handler. + lazy.gExternalProtocolService.loadURI( + lazy.NetUtil.newURI(file), + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + + /** + * Asks for confirmation for launching the specified executable file. This + * can be overridden by regression tests to avoid the interactive prompt. + */ + async confirmLaunchExecutable(path) { + // We don't anchor the prompt to a specific window intentionally, not + // only because this is the same behavior as the system-level prompt, + // but also because the most recently active window is the right choice + // in basically all cases. + return lazy.DownloadUIHelper.getPrompter().confirmLaunchExecutable(path); + }, + + /** + * Launches the specified file, unless overridden by regression tests. + * @note Always use launchDownload() from the outside of this module, it is + * both more powerful and safer. + */ + launchFile(file, mimeInfo) { + if (mimeInfo) { + mimeInfo.launchWithFile(file); + } else { + file.launch(); + } + }, + + /** + * Shows the containing folder of a file. + * + * @param aFilePath + * The path to the file. + * + * @return {Promise} + * @resolves When the instruction to open the containing folder has been + * successfully given to the operating system. Note that + * the OS might still take a while until the folder is actually + * opened. + * @rejects JavaScript exception if there was an error trying to open + * the containing folder. + */ + async showContainingDirectory(aFilePath) { + let file = new lazy.FileUtils.File(aFilePath); + + try { + // Show the directory containing the file and select the file. + file.reveal(); + return; + } catch (ex) {} + + // If reveal fails for some reason (e.g., it's not implemented on unix + // or the file doesn't exist), try using the parent if we have it. + let parent = file.parent; + if (!parent) { + throw new Error( + "Unexpected reference to a top-level directory instead of a file" + ); + } + + try { + // Open the parent directory to show where the file should be. + parent.launch(); + return; + } catch (ex) {} + + // If launch also fails (probably because it's not implemented), let + // the OS handler try to open the parent. + lazy.gExternalProtocolService.loadURI( + lazy.NetUtil.newURI(parent), + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + + /** + * Calls the directory service, create a downloads directory and returns an + * nsIFile for the downloads directory. + * + * @return {Promise} + * @resolves The directory string path. + */ + _createDownloadsDirectory(aName) { + // We read the name of the directory from the list of translated strings + // that is kept by the UI helper module, even if this string is not strictly + // displayed in the user interface. + let directoryPath = PathUtils.join( + this._getDirectory(aName), + lazy.stringBundle.GetStringFromName("downloadsFolder") + ); + + // Create the Downloads folder and ignore if it already exists. + return IOUtils.makeDirectory(directoryPath, { + createAncestors: false, + }).then(() => directoryPath); + }, + + /** + * Returns the string path for the given directory service location name. This + * can be overridden by regression tests to return the path of the system + * temporary directory in all cases. + */ + _getDirectory(name) { + return Services.dirsvc.get(name, Ci.nsIFile).path; + }, + + /** + * Initializes the DownloadSpamProtection instance. + * This is used to observe and group multiple automatic downloads. + */ + _initializeDownloadSpamProtection() { + if (!this.downloadSpamProtection) { + this.downloadSpamProtection = new lazy.DownloadSpamProtection(); + } + }, + + /** + * Register the downloads interruption observers. + * + * @param aList + * The public or private downloads list. + * @param aIsPrivate + * True if the list is private, false otherwise. + * + * @return {Promise} + * @resolves When the views and observers are added. + */ + addListObservers(aList, aIsPrivate) { + DownloadObserver.registerView(aList, aIsPrivate); + if (!DownloadObserver.observersAdded) { + DownloadObserver.observersAdded = true; + for (let topic of kObserverTopics) { + Services.obs.addObserver(DownloadObserver, topic); + } + } + return Promise.resolve(); + }, + + /** + * Force a save on _store if it exists. Used to ensure downloads do not + * persist after being sanitized on Android. + * + * @return {Promise} + * @resolves When _store.save() completes. + */ + forceSave() { + if (this._store) { + return this._store.save(); + } + return Promise.resolve(); + }, +}; + +var DownloadObserver = { + /** + * Flag to determine if the observers have been added previously. + */ + observersAdded: false, + + /** + * Timer used to delay restarting canceled downloads upon waking and returning + * online. + */ + _wakeTimer: null, + + /** + * Set that contains the in progress publics downloads. + * It's kept updated when a public download is added, removed or changes its + * properties. + */ + _publicInProgressDownloads: new Set(), + + /** + * Set that contains the in progress private downloads. + * It's kept updated when a private download is added, removed or changes its + * properties. + */ + _privateInProgressDownloads: new Set(), + + /** + * Set that contains the downloads that have been canceled when going offline + * or to sleep. These are started again when returning online or waking. This + * list is not persisted so when exiting and restarting, the downloads will not + * be started again. + */ + _canceledOfflineDownloads: new Set(), + + /** + * Registers a view that updates the corresponding downloads state set, based + * on the aIsPrivate argument. The set is updated when a download is added, + * removed or changes its properties. + * + * @param aList + * The public or private downloads list. + * @param aIsPrivate + * True if the list is private, false otherwise. + */ + registerView: function DO_registerView(aList, aIsPrivate) { + let downloadsSet = aIsPrivate + ? this._privateInProgressDownloads + : this._publicInProgressDownloads; + let downloadsView = { + onDownloadAdded: aDownload => { + if (!aDownload.stopped) { + downloadsSet.add(aDownload); + } + }, + onDownloadChanged: aDownload => { + if (aDownload.stopped) { + downloadsSet.delete(aDownload); + } else { + downloadsSet.add(aDownload); + } + }, + onDownloadRemoved: aDownload => { + downloadsSet.delete(aDownload); + // The download must also be removed from the canceled when offline set. + this._canceledOfflineDownloads.delete(aDownload); + }, + }; + + // We register the view asynchronously. + aList.addView(downloadsView).catch(console.error); + }, + + /** + * Wrapper that handles the test mode before calling the prompt that display + * a warning message box that informs that there are active downloads, + * and asks whether the user wants to cancel them or not. + * + * @param aCancel + * The observer notification subject. + * @param aDownloadsCount + * The current downloads count. + * @param aPrompter + * The prompter object that shows the confirm dialog. + * @param aPromptType + * The type of prompt notification depending on the observer. + */ + _confirmCancelDownloads: function DO_confirmCancelDownload( + aCancel, + aDownloadsCount, + aPromptType + ) { + // Handle test mode + if (lazy.gCombinedDownloadIntegration._testPromptDownloads) { + lazy.gCombinedDownloadIntegration._testPromptDownloads = aDownloadsCount; + return; + } + + if (!aDownloadsCount) { + return; + } + + // If user has already dismissed the request, then do nothing. + if (aCancel instanceof Ci.nsISupportsPRBool && aCancel.data) { + return; + } + + let prompter = lazy.DownloadUIHelper.getPrompter(); + aCancel.data = prompter.confirmCancelDownloads( + aDownloadsCount, + prompter[aPromptType] + ); + }, + + /** + * Resume all downloads that were paused when going offline, used when waking + * from sleep or returning from being offline. + */ + _resumeOfflineDownloads: function DO_resumeOfflineDownloads() { + this._wakeTimer = null; + + for (let download of this._canceledOfflineDownloads) { + download.start().catch(() => {}); + } + this._canceledOfflineDownloads.clear(); + }, + + // nsIObserver + observe: function DO_observe(aSubject, aTopic, aData) { + let downloadsCount; + switch (aTopic) { + case "quit-application-requested": + downloadsCount = + this._publicInProgressDownloads.size + + this._privateInProgressDownloads.size; + this._confirmCancelDownloads(aSubject, downloadsCount, "ON_QUIT"); + break; + case "offline-requested": + downloadsCount = + this._publicInProgressDownloads.size + + this._privateInProgressDownloads.size; + this._confirmCancelDownloads(aSubject, downloadsCount, "ON_OFFLINE"); + break; + case "last-pb-context-exiting": + downloadsCount = this._privateInProgressDownloads.size; + this._confirmCancelDownloads( + aSubject, + downloadsCount, + "ON_LEAVE_PRIVATE_BROWSING" + ); + break; + case "last-pb-context-exited": + let promise = (async function () { + let list = await Downloads.getList(Downloads.PRIVATE); + let downloads = await list.getAll(); + + // We can remove the downloads and finalize them in parallel. + for (let download of downloads) { + list.remove(download).catch(console.error); + download.finalize(true).catch(console.error); + } + })(); + // Handle test mode + if (lazy.gCombinedDownloadIntegration._testResolveClearPrivateList) { + lazy.gCombinedDownloadIntegration._testResolveClearPrivateList( + promise + ); + } else { + promise.catch(ex => console.error(ex)); + } + break; + case "sleep_notification": + case "suspend_process_notification": + case "network:offline-about-to-go-offline": + // Ignore shutdown notification so aborted downloads will be restarted + // on the next session. + if ( + Services.startup.isInOrBeyondShutdownPhase( + Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED + ) + ) { + break; + } + for (let download of this._publicInProgressDownloads) { + download.cancel(); + this._canceledOfflineDownloads.add(download); + } + for (let download of this._privateInProgressDownloads) { + download.cancel(); + this._canceledOfflineDownloads.add(download); + } + break; + case "wake_notification": + case "resume_process_notification": + let wakeDelay = Services.prefs.getIntPref( + "browser.download.manager.resumeOnWakeDelay", + 10000 + ); + + if (wakeDelay >= 0) { + this._wakeTimer = new Timer( + this._resumeOfflineDownloads.bind(this), + wakeDelay, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + break; + case "network:offline-status-changed": + if (aData == "online") { + this._resumeOfflineDownloads(); + } + break; + // We need to unregister observers explicitly before we reach the + // "xpcom-shutdown" phase, otherwise observers may be notified when some + // required services are not available anymore. We can't unregister + // observers on "quit-application", because this module is also loaded + // during "make package" automation, and the quit notification is not sent + // in that execution environment (bug 973637). + case "xpcom-will-shutdown": + for (let topic of kObserverTopics) { + Services.obs.removeObserver(this, topic); + } + break; + case "blocked-automatic-download": + if (AppConstants.MOZ_BUILD_APP == "browser") { + DownloadIntegration._initializeDownloadSpamProtection(); + DownloadIntegration.downloadSpamProtection.update( + aData, + aSubject.topChromeWindow + ); + } + break; + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), +}; + +/** + * Registers a Places observer so that operations on download history are + * reflected on the provided list of downloads. + * + * You do not need to keep a reference to this object in order to keep it alive, + * because the history service already keeps a strong reference to it. + * + * @param aList + * DownloadList object linked to this observer. + */ +var DownloadHistoryObserver = function (aList) { + this._list = aList; + + const placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["history-cleared", "page-removed"], + placesObserver + ); +}; + +DownloadHistoryObserver.prototype = { + /** + * DownloadList object linked to this observer. + */ + _list: null, + + handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "history-cleared": { + this._list.removeFinished(); + break; + } + case "page-removed": { + if (event.isRemovedFromStore) { + this._list.removeFinished( + download => event.url === download.source.url + ); + } + break; + } + } + } + }, +}; + +/** + * This view can be added to a DownloadList object to trigger a save operation + * in the given DownloadStore object when a relevant change occurs. You should + * call the "initialize" method in order to register the view and load the + * current state from disk. + * + * You do not need to keep a reference to this object in order to keep it alive, + * because the DownloadList object already keeps a strong reference to it. + * + * @param aList + * The DownloadList object on which the view should be registered. + * @param aStore + * The DownloadStore object used for saving. + */ +var DownloadAutoSaveView = function (aList, aStore) { + this._list = aList; + this._store = aStore; + this._downloadsMap = new Map(); + this._writer = new lazy.DeferredTask(() => this._store.save(), kSaveDelayMs); + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "DownloadAutoSaveView: writing data", + () => this._writer.finalize() + ); +}; + +DownloadAutoSaveView.prototype = { + /** + * DownloadList object linked to this view. + */ + _list: null, + + /** + * The DownloadStore object used for saving. + */ + _store: null, + + /** + * True when the initial state of the downloads has been loaded. + */ + _initialized: false, + + /** + * Registers the view and loads the current state from disk. + * + * @return {Promise} + * @resolves When the view has been registered. + * @rejects JavaScript exception. + */ + initialize() { + // We set _initialized to true after adding the view, so that + // onDownloadAdded doesn't cause a save to occur. + return this._list.addView(this).then(() => (this._initialized = true)); + }, + + /** + * This map contains only Download objects that should be saved to disk, and + * associates them with the result of their getSerializationHash function, for + * the purpose of detecting changes to the relevant properties. + */ + _downloadsMap: null, + + /** + * DeferredTask for the save operation. + */ + _writer: null, + + /** + * Called when the list of downloads changed, this triggers the asynchronous + * serialization of the list of downloads. + */ + saveSoon() { + this._writer.arm(); + }, + + // DownloadList callback + onDownloadAdded(aDownload) { + if (lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) { + this._downloadsMap.set(aDownload, aDownload.getSerializationHash()); + if (this._initialized) { + this.saveSoon(); + } + } + }, + + // DownloadList callback + onDownloadChanged(aDownload) { + if (!lazy.gCombinedDownloadIntegration.shouldPersistDownload(aDownload)) { + if (this._downloadsMap.has(aDownload)) { + this._downloadsMap.delete(aDownload); + this.saveSoon(); + } + return; + } + + let hash = aDownload.getSerializationHash(); + if (this._downloadsMap.get(aDownload) != hash) { + this._downloadsMap.set(aDownload, hash); + this.saveSoon(); + } + }, + + // DownloadList callback + onDownloadRemoved(aDownload) { + if (this._downloadsMap.has(aDownload)) { + this._downloadsMap.delete(aDownload); + this.saveSoon(); + } + }, +}; diff --git a/toolkit/components/downloads/DownloadLegacy.sys.mjs b/toolkit/components/downloads/DownloadLegacy.sys.mjs new file mode 100644 index 0000000000..ae38b0d26c --- /dev/null +++ b/toolkit/components/downloads/DownloadLegacy.sys.mjs @@ -0,0 +1,503 @@ +/* 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/. */ + +/** + * This component implements the XPCOM interfaces required for integration with + * the legacy download components. + * + * New code is expected to use the "Downloads.sys.mjs" module directly, without + * going through the interfaces implemented in this XPCOM component. These + * interfaces are only maintained for backwards compatibility with components + * that still work synchronously on the main thread. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadError: "resource://gre/modules/DownloadCore.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +/** + * nsITransfer implementation that provides a bridge to a Download object. + * + * Legacy downloads work differently than the JavaScript implementation. In the + * latter, the caller only provides the properties for the Download object and + * the entire process is handled by the "start" method. In the legacy + * implementation, the caller must create a separate object to execute the + * download, and then make the download visible to the user by hooking it up to + * an nsITransfer instance. + * + * Since nsITransfer instances may be created before the download system is + * initialized, and initialization as well as other operations are asynchronous, + * this implementation is able to delay all progress and status notifications it + * receives until the associated Download object is finally created. + * + * Conversely, the DownloadLegacySaver object can also receive execution and + * cancellation requests asynchronously, before or after it is connected to + * this nsITransfer instance. For that reason, those requests are communicated + * in a potentially deferred way, using promise objects. + * + * The component that executes the download implements nsICancelable to receive + * cancellation requests, but after cancellation it cannot be reused again. + * + * Since the components that execute the download may be different and they + * don't always give consistent results, this bridge takes care of enforcing the + * expectations, for example by ensuring the target file exists when the + * download is successful, even if the source has a size of zero bytes. + */ +export function DownloadLegacyTransfer() { + this._promiseDownload = new Promise(r => (this._resolveDownload = r)); +} + +DownloadLegacyTransfer.prototype = { + classID: Components.ID("{1b4c85df-cbdd-4bb6-b04e-613caece083c}"), + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsIWebProgressListener2", + "nsITransfer", + ]), + + // nsIWebProgressListener + onStateChange: function DLT_onStateChange( + aWebProgress, + aRequest, + aStateFlags, + aStatus + ) { + if (!Components.isSuccessCode(aStatus)) { + this._componentFailed = true; + } + + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_START && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + let blockedByParentalControls = false; + // If it is a failed download, aRequest.responseStatus doesn't exist. + // (missing file on the server, network failure to download) + try { + // If the request's response has been blocked by Windows Parental Controls + // with an HTTP 450 error code, we must cancel the request synchronously. + blockedByParentalControls = + aRequest instanceof Ci.nsIHttpChannel && + aRequest.responseStatus == 450; + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + } + } + + if (blockedByParentalControls) { + aRequest.cancel(Cr.NS_BINDING_ABORTED); + } + + // The main request has just started. Wait for the associated Download + // object to be available before notifying. + this._promiseDownload + .then(download => { + // If the request was blocked, now that we have the download object we + // should set a flag that can be retrieved later when handling the + // cancellation so that the proper error can be thrown. + if (blockedByParentalControls) { + download._blockedByParentalControls = true; + } + + download.saver.onTransferStarted(aRequest); + + // To handle asynchronous cancellation properly, we should hook up the + // handler only after we have been notified that the main request + // started. We will wait until the main request stopped before + // notifying that the download has been canceled. Since the request has + // not completed yet, deferCanceled is guaranteed to be set. + return download.saver.deferCanceled.promise.then(() => { + // Only cancel if the object executing the download is still running. + if (this._cancelable && !this._componentFailed) { + this._cancelable.cancel(Cr.NS_ERROR_ABORT); + } + }); + }) + .catch(console.error); + } else if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + // The last file has been received, or the download failed. Wait for the + // associated Download object to be available before notifying. + this._promiseDownload + .then(download => { + // At this point, the hash has been set and we need to copy it to the + // DownloadSaver. + if (Components.isSuccessCode(aStatus)) { + download.saver.setSha256Hash(this._sha256Hash); + download.saver.setSignatureInfo(this._signatureInfo); + download.saver.setRedirects(this._redirects); + } + download.saver.onTransferFinished(aStatus); + }) + .catch(console.error); + + // Release the reference to the component executing the download. + this._cancelable = null; + } + }, + + // nsIWebProgressListener + onProgressChange: function DLT_onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + this.onProgressChange64( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ); + }, + + onLocationChange() {}, + + // nsIWebProgressListener + onStatusChange: function DLT_onStatusChange( + aWebProgress, + aRequest, + aStatus, + aMessage + ) { + // The status change may optionally be received in addition to the state + // change, but if no network request actually started, it is possible that + // we only receive a status change with an error status code. + if (!Components.isSuccessCode(aStatus)) { + this._componentFailed = true; + + // Wait for the associated Download object to be available. + this._promiseDownload + .then(download => { + download.saver.onTransferFinished(aStatus); + }) + .catch(console.error); + } + }, + + onSecurityChange() {}, + + onContentBlockingEvent() {}, + + // nsIWebProgressListener2 + onProgressChange64: function DLT_onProgressChange64( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + // Since this progress function is invoked frequently, we use a slightly + // more complex solution that optimizes the case where we already have an + // associated Download object, avoiding the Promise overhead. + if (this._download) { + this._hasDelayedProgress = false; + this._download.saver.onProgressBytes( + aCurTotalProgress, + aMaxTotalProgress + ); + return; + } + + // If we don't have a Download object yet, store the most recent progress + // notification to send later. We must do this because there is no guarantee + // that a future notification will be sent if the download stalls. + this._delayedCurTotalProgress = aCurTotalProgress; + this._delayedMaxTotalProgress = aMaxTotalProgress; + + // Do not enqueue multiple callbacks for the progress report. + if (this._hasDelayedProgress) { + return; + } + this._hasDelayedProgress = true; + + this._promiseDownload + .then(download => { + // Check whether an immediate progress report has been already processed + // before we could send the delayed progress report. + if (!this._hasDelayedProgress) { + return; + } + download.saver.onProgressBytes( + this._delayedCurTotalProgress, + this._delayedMaxTotalProgress + ); + }) + .catch(console.error); + }, + _hasDelayedProgress: false, + _delayedCurTotalProgress: 0, + _delayedMaxTotalProgress: 0, + + // nsIWebProgressListener2 + onRefreshAttempted: function DLT_onRefreshAttempted( + aWebProgress, + aRefreshURI, + aMillis, + aSameURI + ) { + // Indicate that refreshes and redirects are allowed by default. However, + // note that download components don't usually call this method at all. + return true; + }, + + // nsITransfer + init: function DLT_init( + aSource, + aSourceOriginalURI, + aTarget, + aDisplayName, + aMIMEInfo, + aStartTime, + aTempFile, + aCancelable, + aIsPrivate, + aDownloadClassification, + aReferrerInfo, + aOpenDownloadsListOnStart + ) { + return this._nsITransferInitInternal( + aSource, + aSourceOriginalURI, + aTarget, + aDisplayName, + aMIMEInfo, + aStartTime, + aTempFile, + aCancelable, + aIsPrivate, + aDownloadClassification, + aReferrerInfo, + aOpenDownloadsListOnStart + ); + }, + + // nsITransfer + initWithBrowsingContext( + aSource, + aTarget, + aDisplayName, + aMIMEInfo, + aStartTime, + aTempFile, + aCancelable, + aIsPrivate, + aDownloadClassification, + aReferrerInfo, + aOpenDownloadsListOnStart, + aBrowsingContext, + aHandleInternally, + aHttpChannel + ) { + let browsingContextId; + let userContextId; + if (aBrowsingContext && aBrowsingContext.currentWindowGlobal) { + browsingContextId = aBrowsingContext.id; + let windowGlobal = aBrowsingContext.currentWindowGlobal; + let originAttributes = windowGlobal.documentPrincipal.originAttributes; + userContextId = originAttributes.userContextId; + } + return this._nsITransferInitInternal( + aSource, + null, + aTarget, + aDisplayName, + aMIMEInfo, + aStartTime, + aTempFile, + aCancelable, + aIsPrivate, + aDownloadClassification, + aReferrerInfo, + aOpenDownloadsListOnStart, + userContextId, + browsingContextId, + aHandleInternally, + aHttpChannel + ); + }, + + _nsITransferInitInternal( + aSource, + aSourceOriginalURI, + aTarget, + aDisplayName, + aMIMEInfo, + aStartTime, + aTempFile, + aCancelable, + isPrivate, + aDownloadClassification, + referrerInfo, + openDownloadsListOnStart = true, + userContextId = 0, + browsingContextId = 0, + handleInternally = false, + aHttpChannel = null + ) { + this._cancelable = aCancelable; + let launchWhenSucceeded = false, + contentType = null, + launcherPath = null; + + if (aMIMEInfo instanceof Ci.nsIMIMEInfo) { + launchWhenSucceeded = + aMIMEInfo.preferredAction != Ci.nsIMIMEInfo.saveToDisk; + contentType = aMIMEInfo.type; + + let appHandler = aMIMEInfo.preferredApplicationHandler; + if ( + aMIMEInfo.preferredAction == Ci.nsIMIMEInfo.useHelperApp && + appHandler instanceof Ci.nsILocalHandlerApp + ) { + launcherPath = appHandler.executable.path; + } + } + // Create a new Download object associated to a DownloadLegacySaver, and + // wait for it to be available. This operation may cause the entire + // download system to initialize before the object is created. + let authHeader = null; + if (aHttpChannel) { + try { + authHeader = aHttpChannel.getRequestHeader("Authorization"); + } catch (e) {} + } + let serialisedDownload = { + source: { + url: aSource.spec, + originalUrl: aSourceOriginalURI && aSourceOriginalURI.spec, + isPrivate, + userContextId, + browsingContextId, + referrerInfo, + authHeader, + }, + target: { + path: aTarget.QueryInterface(Ci.nsIFileURL).file.path, + partFilePath: aTempFile && aTempFile.path, + }, + saver: "legacy", + launchWhenSucceeded, + contentType, + launcherPath, + handleInternally, + openDownloadsListOnStart, + }; + + // In case the Download was classified as insecure/dangerous + // it is already canceled, so we need to generate and attach the + // corresponding error to the download. + if (aDownloadClassification == Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE) { + Services.telemetry + .getKeyedHistogramById("DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD") + .add(lazy.DownloadError.BLOCK_VERDICT_INSECURE, 0); + + serialisedDownload.errorObj = { + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: lazy.DownloadError.BLOCK_VERDICT_INSECURE, + }; + // hasBlockedData needs to be true + // because the unblock UI is hidden if there is + // no data to be unblocked. + serialisedDownload.hasBlockedData = true; + // We cannot use the legacy saver here, as the original channel + // is already closed. A copy saver would create a new channel once + // start() is called. + serialisedDownload.saver = "copy"; + + // Since the download is canceled already, we do not need to keep refrences + this._download = null; + this._cancelable = null; + } + + lazy.Downloads.createDownload(serialisedDownload) + .then(async aDownload => { + // Legacy components keep partial data when they use a ".part" file. + if (aTempFile) { + aDownload.tryToKeepPartialData = true; + } + + // Start the download before allowing it to be controlled. Ignore errors. + aDownload.start().catch(() => {}); + + // Start processing all the other events received through nsITransfer. + this._download = aDownload; + this._resolveDownload(aDownload); + + // Add the download to the list, allowing it to be seen and canceled. + await (await lazy.Downloads.getList(lazy.Downloads.ALL)).add(aDownload); + if (serialisedDownload.errorObj) { + // In case we added an already canceled dummy download + // we need to manually trigger a change event + // as all the animations for finishing downloads are + // listening on onChange. + aDownload._notifyChange(); + } + }) + .catch(console.error); + }, + + setSha256Hash(hash) { + this._sha256Hash = hash; + }, + + setSignatureInfo(signatureInfo) { + this._signatureInfo = signatureInfo; + }, + + setRedirects(redirects) { + this._redirects = redirects; + }, + + /** + * Download object associated with this nsITransfer instance. This is not + * available immediately when the nsITransfer instance is created. + */ + _download: null, + + /** + * Promise that resolves to the Download object associated with this + * nsITransfer instance after the _resolveDownload method is invoked. + * + * Waiting on this promise using "then" ensures that the callbacks are invoked + * in the correct order even if enqueued before the object is available. + */ + _promiseDownload: null, + _resolveDownload: null, + + /** + * Reference to the component that is executing the download. This component + * allows cancellation through its nsICancelable interface. + */ + _cancelable: null, + + /** + * Indicates that the component that executes the download has notified a + * failure condition. In this case, we should never use the component methods + * that cancel the download. + */ + _componentFailed: false, + + /** + * Save the SHA-256 hash in raw bytes of the downloaded file. + */ + _sha256Hash: null, + + /** + * Save the signature info in a serialized protobuf of the downloaded file. + */ + _signatureInfo: null, +}; diff --git a/toolkit/components/downloads/DownloadList.sys.mjs b/toolkit/components/downloads/DownloadList.sys.mjs new file mode 100644 index 0000000000..46f917d16b --- /dev/null +++ b/toolkit/components/downloads/DownloadList.sys.mjs @@ -0,0 +1,668 @@ +/* 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/. */ + +/** + * Provides collections of Download objects and aggregate views on them. + */ + +const FILE_EXTENSIONS = [ + "aac", + "adt", + "adts", + "accdb", + "accde", + "accdr", + "accdt", + "aif", + "aifc", + "aiff", + "apng", + "aspx", + "avi", + "avif", + "bat", + "bin", + "bmp", + "cab", + "cda", + "csv", + "dif", + "dll", + "doc", + "docm", + "docx", + "dot", + "dotx", + "eml", + "eps", + "exe", + "flac", + "flv", + "gif", + "htm", + "html", + "ico", + "ini", + "iso", + "jar", + "jfif", + "jpg", + "jpeg", + "json", + "m4a", + "mdb", + "mid", + "midi", + "mov", + "mp3", + "mp4", + "mpeg", + "mpg", + "msi", + "mui", + "oga", + "ogg", + "ogv", + "opus", + "pdf", + "pjpeg", + "pjp", + "png", + "pot", + "potm", + "potx", + "ppam", + "pps", + "ppsm", + "ppsx", + "ppt", + "pptm", + "pptx", + "psd", + "pst", + "pub", + "rar", + "rdf", + "rtf", + "shtml", + "sldm", + "sldx", + "svg", + "swf", + "sys", + "tif", + "tiff", + "tmp", + "txt", + "vob", + "vsd", + "vsdm", + "vsdx", + "vss", + "vssm", + "vst", + "vstm", + "vstx", + "wav", + "wbk", + "webm", + "webp", + "wks", + "wma", + "wmd", + "wmv", + "wmz", + "wms", + "wpd", + "wp5", + "xht", + "xhtml", + "xla", + "xlam", + "xll", + "xlm", + "xls", + "xlsm", + "xlsx", + "xlt", + "xltm", + "xltx", + "xml", + "zip", +]; + +const TELEMETRY_EVENT_CATEGORY = "downloads"; + +/** + * Represents a collection of Download objects that can be viewed and managed by + * the user interface, and persisted across sessions. + */ +export class DownloadList { + constructor() { + /** + * Array of Download objects currently in the list. + */ + this._downloads = []; + + /** + * Set of currently registered views. + */ + this._views = new Set(); + } + + /** + * Retrieves a snapshot of the downloads that are currently in the list. The + * returned array does not change when downloads are added or removed, though + * the Download objects it contains are still updated in real time. + * + * @return {Promise} + * @resolves An array of Download objects. + * @rejects JavaScript exception. + */ + async getAll() { + return Array.from(this._downloads); + } + + /** + * Adds a new download to the end of the items list. + * + * @note When a download is added to the list, its "onchange" event is + * registered by the list, thus it cannot be used to monitor the + * download. To receive change notifications for downloads that are + * added to the list, use the addView method to register for + * onDownloadChanged notifications. + * + * @param download + * The Download object to add. + * + * @return {Promise} + * @resolves When the download has been added. + * @rejects JavaScript exception. + */ + async add(download) { + this._downloads.push(download); + download.onchange = this._change.bind(this, download); + this._notifyAllViews("onDownloadAdded", download); + } + + /** + * Removes a download from the list. If the download was already removed, + * this method has no effect. + * + * This method does not change the state of the download, to allow adding it + * to another list, or control it directly. If you want to dispose of the + * download object, you should cancel it afterwards, and remove any partially + * downloaded data if needed. + * + * @param download + * The Download object to remove. + * + * @return {Promise} + * @resolves When the download has been removed. + * @rejects JavaScript exception. + */ + async remove(download) { + let index = this._downloads.indexOf(download); + if (index != -1) { + this._downloads.splice(index, 1); + download.onchange = null; + this._notifyAllViews("onDownloadRemoved", download); + } + } + + /** + * This function is called when "onchange" events of downloads occur. + * + * @param download + * The Download object that changed. + */ + _change(download) { + this._notifyAllViews("onDownloadChanged", download); + } + + /** + * Adds a view that will be notified of changes to downloads. The newly added + * view will receive onDownloadAdded notifications for all the downloads that + * are already in the list. + * + * @param view + * The view object to add. The following methods may be defined: + * { + * onDownloadAdded: function (download) { + * // Called after download is added to the end of the list. + * }, + * onDownloadChanged: function (download) { + * // Called after the properties of download change. + * }, + * onDownloadRemoved: function (download) { + * // Called after download is removed from the list. + * }, + * onDownloadBatchStarting: function () { + * // Called before multiple changes are made at the same time. + * }, + * onDownloadBatchEnded: function () { + * // Called after all the changes have been made. + * }, + * } + * + * @return {Promise} + * @resolves When the view has been registered and all the onDownloadAdded + * notifications for the existing downloads have been sent. + * @rejects JavaScript exception. + */ + async addView(view) { + this._views.add(view); + + if ("onDownloadAdded" in view) { + this._notifyAllViews("onDownloadBatchStarting"); + for (let download of this._downloads) { + try { + view.onDownloadAdded(download); + } catch (ex) { + console.error(ex); + } + } + this._notifyAllViews("onDownloadBatchEnded"); + } + } + + /** + * Removes a view that was previously added using addView. + * + * @param view + * The view object to remove. + * + * @return {Promise} + * @resolves When the view has been removed. At this point, the removed view + * will not receive any more notifications. + * @rejects JavaScript exception. + */ + async removeView(view) { + this._views.delete(view); + } + + /** + * Notifies all the views of a download addition, change, removal, or other + * event. The additional arguments are passed to the called method. + * + * @param methodName + * String containing the name of the method to call on the view. + */ + _notifyAllViews(methodName, ...args) { + for (let view of this._views) { + try { + if (methodName in view) { + view[methodName](...args); + } + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Removes downloads from the list that have finished, have failed, or have + * been canceled without keeping partial data. A filter function may be + * specified to remove only a subset of those downloads. + * + * This method finalizes each removed download, ensuring that any partially + * downloaded data associated with it is also removed. + * + * @param filterFn + * The filter function is called with each download as its only + * argument, and should return true to remove the download and false + * to keep it. This parameter may be null or omitted to have no + * additional filter. + */ + removeFinished(filterFn) { + (async () => { + let list = await this.getAll(); + for (let download of list) { + // Remove downloads that have been canceled, even if the cancellation + // operation hasn't completed yet so we don't check "stopped" here. + // Failed downloads with partial data are also removed. + if ( + download.stopped && + (!download.hasPartialData || download.error) && + (!filterFn || filterFn(download)) + ) { + // Remove the download first, so that the views don't get the change + // notifications that may occur during finalization. + await this.remove(download); + // Find if a file with the same path is also downloading. + let sameFileIsDownloading = false; + for (let otherDownload of await this.getAll()) { + if ( + download !== otherDownload && + download.target.path == otherDownload.target.path && + !otherDownload.error + ) { + sameFileIsDownloading = true; + } + } + // Ensure that the download is stopped and no partial data is kept. + // This works even if the download state has changed meanwhile. We + // don't need to wait for the procedure to be complete before + // processing the other downloads in the list. + let removePartialData = !sameFileIsDownloading; + download.finalize(removePartialData).catch(console.error); + } + } + })().catch(console.error); + } +} + +/** + * Provides a unified, unordered list combining public and private downloads. + * + * Download objects added to this list are also added to one of the two + * underlying lists, based on their "source.isPrivate" property. Views on this + * list will receive notifications for both public and private downloads. + * + * @param publicList + * Underlying DownloadList containing public downloads. + * @param privateList + * Underlying DownloadList containing private downloads. + */ +export class DownloadCombinedList extends DownloadList { + constructor(publicList, privateList) { + super(); + + /** + * Underlying DownloadList containing public downloads. + */ + this._publicList = publicList; + + /** + * Underlying DownloadList containing private downloads. + */ + this._privateList = privateList; + + publicList.addView(this).catch(console.error); + privateList.addView(this).catch(console.error); + } + + /** + * Adds a new download to the end of the items list. + * + * @note When a download is added to the list, its "onchange" event is + * registered by the list, thus it cannot be used to monitor the + * download. To receive change notifications for downloads that are + * added to the list, use the addView method to register for + * onDownloadChanged notifications. + * + * @param download + * The Download object to add. + * + * @return {Promise} + * @resolves When the download has been added. + * @rejects JavaScript exception. + */ + add(download) { + let extension = download.target.path.split(".").pop(); + + if (!FILE_EXTENSIONS.includes(extension)) { + extension = "other"; + } + + try { + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + "added", + "fileExtension", + extension, + {} + ); + } catch (ex) { + console.error( + "DownloadsCommon: error recording telemetry event.", + ex.message + ); + } + + if (download.source.isPrivate) { + return this._privateList.add(download); + } + + return this._publicList.add(download); + } + + /** + * Removes a download from the list. If the download was already removed, + * this method has no effect. + * + * This method does not change the state of the download, to allow adding it + * to another list, or control it directly. If you want to dispose of the + * download object, you should cancel it afterwards, and remove any partially + * downloaded data if needed. + * + * @param download + * The Download object to remove. + * + * @return {Promise} + * @resolves When the download has been removed. + * @rejects JavaScript exception. + */ + remove(download) { + if (download.source.isPrivate) { + return this._privateList.remove(download); + } + return this._publicList.remove(download); + } + + // DownloadList callback + onDownloadAdded(download) { + this._downloads.push(download); + this._notifyAllViews("onDownloadAdded", download); + } + + // DownloadList callback + onDownloadChanged(download) { + this._notifyAllViews("onDownloadChanged", download); + } + + // DownloadList callback + onDownloadRemoved(download) { + let index = this._downloads.indexOf(download); + if (index != -1) { + this._downloads.splice(index, 1); + } + this._notifyAllViews("onDownloadRemoved", download); + } +} +/** + * Provides an aggregated view on the contents of a DownloadList. + */ +export class DownloadSummary { + constructor() { + /** + * Array of Download objects that are currently part of the summary. + */ + this._downloads = []; + + /** + * Set of currently registered views. + */ + this._views = new Set(); + } + + /** + * Underlying DownloadList whose contents should be summarized. + */ + _list = null; + + /** + * Indicates whether all the downloads are currently stopped. + */ + allHaveStopped = true; + + /** + * Indicates whether whether all downloads have an unknown final size. + */ + allUnknownSize = true; + + /** + * Indicates the total number of bytes to be transferred before completing all + * the downloads that are currently in progress. + * + * For downloads that do not have a known final size, the number of bytes + * currently transferred is reported as part of this property. + * + * This is zero if no downloads are currently in progress. + */ + progressTotalBytes = 0; + + /** + * Number of bytes currently transferred as part of all the downloads that are + * currently in progress. + * + * This is zero if no downloads are currently in progress. + */ + progressCurrentBytes = 0; + + /** + * This method may be called once to bind this object to a DownloadList. + * + * Views on the summarized data can be registered before this object is bound + * to an actual list. This allows the summary to be used without requiring + * the initialization of the DownloadList first. + * + * @param list + * Underlying DownloadList whose contents should be summarized. + * + * @return {Promise} + * @resolves When the view on the underlying list has been registered. + * @rejects JavaScript exception. + */ + async bindToList(list) { + if (this._list) { + throw new Error("bindToList may be called only once."); + } + + await list.addView(this); + // Set the list reference only after addView has returned, so that we don't + // send a notification to our views for each download that is added. + this._list = list; + this._onListChanged(); + } + + /** + * Adds a view that will be notified of changes to the summary. The newly + * added view will receive an initial onSummaryChanged notification. + * + * @param view + * The view object to add. The following methods may be defined: + * { + * onSummaryChanged: function () { + * // Called after any property of the summary has changed. + * }, + * } + * + * @return {Promise} + * @resolves When the view has been registered and the onSummaryChanged + * notification has been sent. + * @rejects JavaScript exception. + */ + async addView(view) { + this._views.add(view); + + if ("onSummaryChanged" in view) { + try { + view.onSummaryChanged(); + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Removes a view that was previously added using addView. + * + * @param view + * The view object to remove. + * + * @return {Promise} + * @resolves When the view has been removed. At this point, the removed view + * will not receive any more notifications. + * @rejects JavaScript exception. + */ + async removeView(view) { + this._views.delete(view); + } + + /** + * This function is called when any change in the list of downloads occurs, + * and will recalculate the summary and notify the views in case the + * aggregated properties are different. + */ + _onListChanged() { + let allHaveStopped = true; + let allUnknownSize = true; + let progressTotalBytes = 0; + let progressCurrentBytes = 0; + + // Recalculate the aggregated state. See the description of the individual + // properties for an explanation of the summarization logic. + for (let download of this._downloads) { + if (!download.stopped) { + allHaveStopped = false; + if (download.hasProgress) { + allUnknownSize = false; + progressTotalBytes += download.totalBytes; + } else { + progressTotalBytes += download.currentBytes; + } + progressCurrentBytes += download.currentBytes; + } + } + + // Exit now if the properties did not change. + if ( + this.allHaveStopped == allHaveStopped && + this.allUnknownSize == allUnknownSize && + this.progressTotalBytes == progressTotalBytes && + this.progressCurrentBytes == progressCurrentBytes + ) { + return; + } + + this.allHaveStopped = allHaveStopped; + this.allUnknownSize = allUnknownSize; + this.progressTotalBytes = progressTotalBytes; + this.progressCurrentBytes = progressCurrentBytes; + + // Notify all the views that our properties changed. + for (let view of this._views) { + try { + if ("onSummaryChanged" in view) { + view.onSummaryChanged(); + } + } catch (ex) { + console.error(ex); + } + } + } + + // DownloadList callback + onDownloadAdded(download) { + this._downloads.push(download); + if (this._list) { + this._onListChanged(); + } + } + + // DownloadList callback + onDownloadChanged(download) { + this._onListChanged(); + } + + // DownloadList callback + onDownloadRemoved(download) { + let index = this._downloads.indexOf(download); + if (index != -1) { + this._downloads.splice(index, 1); + } + this._onListChanged(); + } +} diff --git a/toolkit/components/downloads/DownloadPaths.sys.mjs b/toolkit/components/downloads/DownloadPaths.sys.mjs new file mode 100644 index 0000000000..f1e0a080c2 --- /dev/null +++ b/toolkit/components/downloads/DownloadPaths.sys.mjs @@ -0,0 +1,102 @@ +/* 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/. */ + +/** + * Provides methods for giving names and paths to files being downloaded. + */ + +export var DownloadPaths = { + /** + * Sanitizes an arbitrary string via mimeSvc.validateFileNameForSaving. + * + * If the filename being validated is one that was returned from a + * file picker, pass false for compressWhitespaces and true for + * allowInvalidFilenames. Otherwise, the default values of the arguments + * should generally be used. + * + * @param {string} leafName The full leaf name to sanitize + * @param {boolean} [compressWhitespaces] Whether consecutive whitespaces + * should be compressed. The default value is true. + * @param {boolean} [allowInvalidFilenames] Allow invalid and dangerous + * filenames and extensions as is. + */ + sanitize( + leafName, + { compressWhitespaces = true, allowInvalidFilenames = false } = {} + ) { + const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService); + + let flags = mimeSvc.VALIDATE_SANITIZE_ONLY | mimeSvc.VALIDATE_DONT_TRUNCATE; + if (!compressWhitespaces) { + flags |= mimeSvc.VALIDATE_DONT_COLLAPSE_WHITESPACE; + } + if (allowInvalidFilenames) { + flags |= mimeSvc.VALIDATE_ALLOW_INVALID_FILENAMES; + } + return mimeSvc.validateFileNameForSaving(leafName, "", flags); + }, + + /** + * Creates a uniquely-named file starting from the name of the provided file. + * If a file with the provided name already exists, the function attempts to + * create nice alternatives, like "base(1).ext" (instead of "base-1.ext"). + * + * If a unique name cannot be found, the function throws the XPCOM exception + * NS_ERROR_FILE_TOO_BIG. Other exceptions, like NS_ERROR_FILE_ACCESS_DENIED, + * can also be expected. + * + * @param templateFile + * nsIFile whose leaf name is going to be used as a template. The + * provided object is not modified. + * + * @return A new instance of an nsIFile object pointing to the newly created + * empty file. On platforms that support permission bits, the file is + * created with permissions 644. + */ + createNiceUniqueFile(templateFile) { + // Work on a clone of the provided template file object. + let curFile = templateFile.clone().QueryInterface(Ci.nsIFile); + let [base, ext] = DownloadPaths.splitBaseNameAndExtension(curFile.leafName); + // Try other file names, for example "base(1).txt" or "base(1).tar.gz", + // only if the file name initially set already exists. + for (let i = 1; i < 10000 && curFile.exists(); i++) { + curFile.leafName = base + "(" + i + ")" + ext; + } + // At this point we hand off control to createUnique, which will create the + // file with the name we chose, if it is valid. If not, createUnique will + // attempt to modify it again, for example it will shorten very long names + // that can't be created on some platforms, and for which a normal call to + // nsIFile.create would result in NS_ERROR_FILE_NOT_FOUND. This can result + // very rarely in strange names like "base(9999).tar-1.gz" or "ba-1.gz". + curFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + return curFile; + }, + + /** + * Separates the base name from the extension in a file name, recognizing some + * double extensions like ".tar.gz". + * + * @param leafName + * The full leaf name to be parsed. Be careful when processing names + * containing leading or trailing dots or spaces. + * + * @return [base, ext] + * The base name of the file, which can be empty, and its extension, + * which always includes the leading dot unless it's an empty string. + * Concatenating the two items always results in the original name. + */ + splitBaseNameAndExtension(leafName) { + // The following regular expression is built from these key parts: + // .*? Matches the base name non-greedily. + // \.[A-Z0-9]{1,3} Up to three letters or numbers preceding a + // double extension. + // \.(?:gz|bz2|Z) The second part of common double extensions. + // \.[^.]* Matches any extension or a single trailing dot. + let [, base, ext] = /(.*?)(\.[A-Z0-9]{1,3}\.(?:gz|bz2|Z)|\.[^.]*)?$/i.exec( + leafName + ); + // Return an empty string instead of undefined if no extension is found. + return [base, ext || ""]; + }, +}; diff --git a/toolkit/components/downloads/DownloadPlatform.cpp b/toolkit/components/downloads/DownloadPlatform.cpp new file mode 100644 index 0000000000..ee2e0a3248 --- /dev/null +++ b/toolkit/components/downloads/DownloadPlatform.cpp @@ -0,0 +1,319 @@ +/* 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/. */ + +#include "DownloadPlatform.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsINestedURI.h" +#include "nsIProtocolHandler.h" +#include "nsIURI.h" +#include "nsIFile.h" +#include "xpcpublic.h" + +#include "mozilla/dom/Promise.h" +#include "mozilla/Preferences.h" + +#define PREF_BDM_ADDTORECENTDOCS "browser.download.manager.addToRecentDocs" + +#ifdef XP_WIN +# include <shlobj.h> +# include <urlmon.h> +# include "nsILocalFileWin.h" +# include "WinTaskbar.h" +#endif + +#ifdef XP_MACOSX +# include <CoreFoundation/CoreFoundation.h> +# include "../../../xpcom/io/CocoaFileUtils.h" +#endif + +#ifdef MOZ_WIDGET_GTK +# include <gtk/gtk.h> +#endif + +using namespace mozilla; +using dom::Promise; + +DownloadPlatform* DownloadPlatform::gDownloadPlatformService = nullptr; + +NS_IMPL_ISUPPORTS(DownloadPlatform, mozIDownloadPlatform); + +DownloadPlatform* DownloadPlatform::GetDownloadPlatform() { + if (!gDownloadPlatformService) { + gDownloadPlatformService = new DownloadPlatform(); + } + + NS_ADDREF(gDownloadPlatformService); + + return gDownloadPlatformService; +} + +#ifdef MOZ_WIDGET_GTK +static void gio_set_metadata_done(GObject* source_obj, GAsyncResult* res, + gpointer user_data) { + GError* err = nullptr; + g_file_set_attributes_finish(G_FILE(source_obj), res, nullptr, &err); + if (err) { +# ifdef DEBUG + NS_DebugBreak(NS_DEBUG_WARNING, "Set file metadata failed: ", err->message, + __FILE__, __LINE__); +# endif + g_error_free(err); + } +} +#endif + +#ifdef XP_MACOSX +// Caller is responsible for freeing any result (CF Create Rule) +CFURLRef CreateCFURLFromNSIURI(nsIURI* aURI) { + nsAutoCString spec; + if (aURI) { + aURI->GetSpec(spec); + } + + CFStringRef urlStr = ::CFStringCreateWithCString( + kCFAllocatorDefault, spec.get(), kCFStringEncodingUTF8); + if (!urlStr) { + return NULL; + } + + CFURLRef url = ::CFURLCreateWithString(kCFAllocatorDefault, urlStr, NULL); + + ::CFRelease(urlStr); + + return url; +} +#endif + +#ifdef XP_WIN +static void AddToRecentDocs(nsIFile* aTarget, nsAutoString& aPath) { + nsString modelId; + if (mozilla::widget::WinTaskbar::GetAppUserModelID(modelId)) { + nsCOMPtr<nsIURI> uri; + if (NS_SUCCEEDED(NS_NewFileURI(getter_AddRefs(uri), aTarget)) && uri) { + nsCString spec; + if (NS_SUCCEEDED(uri->GetSpec(spec))) { + IShellItem2* psi = nullptr; + if (SUCCEEDED( + SHCreateItemFromParsingName(NS_ConvertASCIItoUTF16(spec).get(), + nullptr, IID_PPV_ARGS(&psi)))) { + SHARDAPPIDINFO info = {psi, modelId.get()}; + ::SHAddToRecentDocs(SHARD_APPIDINFO, &info); + psi->Release(); + return; + } + } + } + } + + ::SHAddToRecentDocs(SHARD_PATHW, aPath.get()); +} +#endif + +nsresult DownloadPlatform::DownloadDone(nsIURI* aSource, nsIURI* aReferrer, + nsIFile* aTarget, + const nsACString& aContentType, + bool aIsPrivate, JSContext* aCx, + Promise** aPromise) { + nsIGlobalObject* globalObject = + xpc::NativeGlobal(JS::CurrentGlobalOrNull(aCx)); + + if (NS_WARN_IF(!globalObject)) { + return NS_ERROR_FAILURE; + } + + ErrorResult result; + RefPtr<Promise> promise = Promise::Create(globalObject, result); + + if (NS_WARN_IF(result.Failed())) { + return result.StealNSResult(); + } + + nsresult rv = NS_OK; + bool pendingAsyncOperations = false; + +#if defined(XP_WIN) || defined(XP_MACOSX) || defined(MOZ_WIDGET_ANDROID) || \ + defined(MOZ_WIDGET_GTK) + + nsAutoString path; + if (aTarget && NS_SUCCEEDED(aTarget->GetPath(path))) { +# if defined(XP_WIN) || defined(MOZ_WIDGET_GTK) || defined(MOZ_WIDGET_ANDROID) + // On Windows and Gtk, add the download to the system's "recent documents" + // list, with a pref to disable. + { +# ifndef MOZ_WIDGET_ANDROID + bool addToRecentDocs = Preferences::GetBool(PREF_BDM_ADDTORECENTDOCS); + if (addToRecentDocs && !aIsPrivate) { +# ifdef XP_WIN + AddToRecentDocs(aTarget, path); +# elif defined(MOZ_WIDGET_GTK) + GtkRecentManager* manager = gtk_recent_manager_get_default(); + + gchar* uri = g_filename_to_uri(NS_ConvertUTF16toUTF8(path).get(), + nullptr, nullptr); + if (uri) { + gtk_recent_manager_add_item(manager, uri); + g_free(uri); + } +# endif + } +# endif +# ifdef MOZ_WIDGET_GTK + // Private window should not leak URI to the system (Bug 1535950) + if (!aIsPrivate) { + // Use GIO to store the source URI for later display in the file + // manager. + GFile* gio_file = + g_file_new_for_path(NS_ConvertUTF16toUTF8(path).get()); + nsCString source_uri; + nsresult rv = aSource->GetSpec(source_uri); + NS_ENSURE_SUCCESS(rv, rv); + GFileInfo* file_info = g_file_info_new(); + g_file_info_set_attribute_string(file_info, "metadata::download-uri", + source_uri.get()); + g_file_set_attributes_async(gio_file, file_info, G_FILE_QUERY_INFO_NONE, + G_PRIORITY_DEFAULT, nullptr, + gio_set_metadata_done, nullptr); + g_object_unref(file_info); + g_object_unref(gio_file); + } +# endif + } +# endif + +# ifdef XP_MACOSX + // On OS X, make the downloads stack bounce. + CFStringRef observedObject = ::CFStringCreateWithCString( + kCFAllocatorDefault, NS_ConvertUTF16toUTF8(path).get(), + kCFStringEncodingUTF8); + CFNotificationCenterRef center = + ::CFNotificationCenterGetDistributedCenter(); + ::CFNotificationCenterPostNotification( + center, CFSTR("com.apple.DownloadFileFinished"), observedObject, + nullptr, TRUE); + ::CFRelease(observedObject); + + // Add OS X origin and referrer file metadata + CFStringRef pathCFStr = NULL; + if (!path.IsEmpty()) { + pathCFStr = ::CFStringCreateWithCharacters( + kCFAllocatorDefault, (const UniChar*)path.get(), path.Length()); + } + if (pathCFStr && !aIsPrivate) { + bool isFromWeb = IsURLPossiblyFromWeb(aSource); + nsCOMPtr<nsIURI> source(aSource); + nsCOMPtr<nsIURI> referrer(aReferrer); + + rv = NS_DispatchBackgroundTask( + NS_NewRunnableFunction( + "DownloadPlatform::DownloadDone", + [pathCFStr, isFromWeb, source, referrer, promise]() mutable { + CFURLRef sourceCFURL = CreateCFURLFromNSIURI(source); + CFURLRef referrerCFURL = CreateCFURLFromNSIURI(referrer); + + CocoaFileUtils::AddOriginMetadataToFile(pathCFStr, sourceCFURL, + referrerCFURL); + CocoaFileUtils::AddQuarantineMetadataToFile( + pathCFStr, sourceCFURL, referrerCFURL, isFromWeb); + ::CFRelease(pathCFStr); + if (sourceCFURL) { + ::CFRelease(sourceCFURL); + } + if (referrerCFURL) { + ::CFRelease(referrerCFURL); + } + + DebugOnly<nsresult> rv = + NS_DispatchToMainThread(NS_NewRunnableFunction( + "DownloadPlatform::DownloadDoneResolve", + [promise = std::move(promise)]() { + promise->MaybeResolveWithUndefined(); + })); + MOZ_ASSERT(NS_SUCCEEDED(rv)); + // In non-debug builds, if we've for some reason failed to + // dispatch a runnable to the main thread to resolve the + // Promise, then it's unlikely we can reject it either. At that + // point, the Promise is going to remain in pending limbo until + // its global goes away. + }), + NS_DISPATCH_EVENT_MAY_BLOCK); + + if (NS_SUCCEEDED(rv)) { + pendingAsyncOperations = true; + } + } +# endif + } + +#endif + + if (!pendingAsyncOperations) { + promise->MaybeResolveWithUndefined(); + } + promise.forget(aPromise); + return rv; +} + +nsresult DownloadPlatform::MapUrlToZone(const nsAString& aURL, + uint32_t* aZone) { +#ifdef XP_WIN + RefPtr<IInternetSecurityManager> inetSecMgr; + if (FAILED(CoCreateInstance(CLSID_InternetSecurityManager, NULL, CLSCTX_ALL, + IID_IInternetSecurityManager, + getter_AddRefs(inetSecMgr)))) { + return NS_ERROR_UNEXPECTED; + } + + DWORD zone; + if (inetSecMgr->MapUrlToZone(PromiseFlatString(aURL).get(), &zone, 0) != + S_OK) { + return NS_ERROR_UNEXPECTED; + } else { + *aZone = zone; + } + + return NS_OK; +#else + return NS_ERROR_NOT_IMPLEMENTED; +#endif +} + +// Check if a URI is likely to be web-based, by checking its URI flags. +// If in doubt (e.g. if anything fails during the check) claims things +// are from the web. +bool DownloadPlatform::IsURLPossiblyFromWeb(nsIURI* aURI) { + nsCOMPtr<nsIIOService> ios = do_GetIOService(); + nsCOMPtr<nsIURI> uri = aURI; + if (!ios) { + return true; + } + + while (uri) { + // We're not using NS_URIChainHasFlags because we're checking for *any* of 3 + // flags to be present on *all* of the nested URIs, which it can't do. + uint32_t flags; + nsresult rv = ios->GetDynamicProtocolFlags(uri, &flags); + if (NS_FAILED(rv)) { + return true; + } + // If not dangerous to load, not a UI resource and not a local file, + // assume this is from the web: + if (!(flags & nsIProtocolHandler::URI_DANGEROUS_TO_LOAD) && + !(flags & nsIProtocolHandler::URI_IS_UI_RESOURCE) && + !(flags & nsIProtocolHandler::URI_IS_LOCAL_FILE)) { + return true; + } + // Otherwise, check if the URI is nested, and if so go through + // the loop again: + nsCOMPtr<nsINestedURI> nestedURI = do_QueryInterface(uri); + uri = nullptr; + if (nestedURI) { + rv = nestedURI->GetInnerURI(getter_AddRefs(uri)); + if (NS_FAILED(rv)) { + return true; + } + } + } + return false; +} diff --git a/toolkit/components/downloads/DownloadPlatform.h b/toolkit/components/downloads/DownloadPlatform.h new file mode 100644 index 0000000000..82e74f8c49 --- /dev/null +++ b/toolkit/components/downloads/DownloadPlatform.h @@ -0,0 +1,28 @@ +/* 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/. */ + +#ifndef __DownloadPlatform_h__ +#define __DownloadPlatform_h__ + +#include "mozIDownloadPlatform.h" + +#include "nsISupports.h" + +class DownloadPlatform : public mozIDownloadPlatform { + protected: + virtual ~DownloadPlatform() = default; + + public: + NS_DECL_ISUPPORTS + NS_DECL_MOZIDOWNLOADPLATFORM + + static DownloadPlatform* gDownloadPlatformService; + + static DownloadPlatform* GetDownloadPlatform(); + + private: + static bool IsURLPossiblyFromWeb(nsIURI* aURI); +}; + +#endif diff --git a/toolkit/components/downloads/DownloadStore.sys.mjs b/toolkit/components/downloads/DownloadStore.sys.mjs new file mode 100644 index 0000000000..542a8c765a --- /dev/null +++ b/toolkit/components/downloads/DownloadStore.sys.mjs @@ -0,0 +1,211 @@ +/* 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/. */ + +/** + * Handles serialization of Download objects and persistence into a file, so + * that the state of downloads can be restored across sessions. + * + * The file is stored in JSON format, without indentation. With indentation + * applied, the file would look like this: + * + * { + * "list": [ + * { + * "source": "http://www.example.com/download.txt", + * "target": "/home/user/Downloads/download.txt" + * }, + * { + * "source": { + * "url": "http://www.example.com/download.txt", + * "referrerInfo": serialized string represents referrerInfo object + * }, + * "target": "/home/user/Downloads/download-2.txt" + * } + * ] + * } + */ + +// Time after which insecure downloads that have not been dealt with on shutdown +// get removed (5 minutes). +const MAX_INSECURE_DOWNLOAD_AGE_MS = 5 * 60 * 1000; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gTextDecoder", function () { + return new TextDecoder(); +}); + +ChromeUtils.defineLazyGetter(lazy, "gTextEncoder", function () { + return new TextEncoder(); +}); + +/** + * Handles serialization of Download objects and persistence into a file, so + * that the state of downloads can be restored across sessions. + * + * @param aList + * DownloadList object to be populated or serialized. + * @param aPath + * String containing the file path where data should be saved. + */ +export var DownloadStore = function (aList, aPath) { + this.list = aList; + this.path = aPath; +}; + +DownloadStore.prototype = { + /** + * DownloadList object to be populated or serialized. + */ + list: null, + + /** + * String containing the file path where data should be saved. + */ + path: "", + + /** + * This function is called with a Download object as its first argument, and + * should return true if the item should be saved. + */ + onsaveitem: () => true, + + /** + * Loads persistent downloads from the file to the list. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + load: function DS_load() { + return (async () => { + let bytes; + try { + bytes = await IOUtils.read(this.path); + } catch (ex) { + if (!(ex.name == "NotFoundError")) { + throw ex; + } + // If the file does not exist, there are no downloads to load. + return; + } + + // Set this to true when we make changes to the download list that should + // be reflected in the file again. + let storeChanges = false; + let removePromises = []; + let storeData = JSON.parse(lazy.gTextDecoder.decode(bytes)); + + // Create live downloads based on the static snapshot. + for (let downloadData of storeData.list) { + try { + let download = await lazy.Downloads.createDownload(downloadData); + + // Insecure downloads that have not been dealt with on shutdown should + // get cleaned up and removed from the download list on restart unless + // they are very new + if ( + download.error?.becauseBlockedByReputationCheck && + download.error.reputationCheckVerdict == "Insecure" && + Date.now() - download.startTime > MAX_INSECURE_DOWNLOAD_AGE_MS + ) { + removePromises.push(download.removePartialData()); + storeChanges = true; + continue; + } + + try { + if (!download.succeeded && !download.canceled && !download.error) { + // Try to restart the download if it was in progress during the + // previous session. Ignore errors. + download.start().catch(() => {}); + } else { + // If the download was not in progress, try to update the current + // progress from disk. This is relevant in case we retained + // partially downloaded data. + await download.refresh(); + } + } finally { + // Add the download to the list if we succeeded in creating it, + // after we have updated its initial state. + await this.list.add(download); + } + } catch (ex) { + // If an item is unrecognized, don't prevent others from being loaded. + console.error(ex); + } + } + + if (storeChanges) { + try { + await Promise.all(removePromises); + await this.save(); + } catch (ex) { + console.error(ex); + } + } + })(); + }, + + /** + * Saves persistent downloads from the list to the file. + * + * If an error occurs, the previous file is not deleted. + * + * @return {Promise} + * @resolves When the operation finished successfully. + * @rejects JavaScript exception. + */ + save: function DS_save() { + return (async () => { + let downloads = await this.list.getAll(); + + // Take a static snapshot of the current state of all the downloads. + let storeData = { list: [] }; + let atLeastOneDownload = false; + for (let download of downloads) { + try { + if (!this.onsaveitem(download)) { + continue; + } + + let serializable = download.toSerializable(); + if (!serializable) { + // This item cannot be persisted across sessions. + continue; + } + storeData.list.push(serializable); + atLeastOneDownload = true; + } catch (ex) { + // If an item cannot be converted to a serializable form, don't + // prevent others from being saved. + console.error(ex); + } + } + + if (atLeastOneDownload) { + // Create or overwrite the file if there are downloads to save. + let bytes = lazy.gTextEncoder.encode(JSON.stringify(storeData)); + await IOUtils.write(this.path, bytes, { + tmpPath: this.path + ".tmp", + }); + } else { + // Remove the file if there are no downloads to save at all. + try { + await IOUtils.remove(this.path); + } catch (ex) { + if (!(ex.name == "NotFoundError" || ex.name == "NotAllowedError")) { + throw ex; + } + // On Windows, we may get an access denied error instead of a no such + // file error if the file existed before, and was recently deleted. + } + } + })(); + }, +}; diff --git a/toolkit/components/downloads/DownloadUIHelper.sys.mjs b/toolkit/components/downloads/DownloadUIHelper.sys.mjs new file mode 100644 index 0000000000..0d1d0e0a31 --- /dev/null +++ b/toolkit/components/downloads/DownloadUIHelper.sys.mjs @@ -0,0 +1,240 @@ +/* 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/. */ + +/** + * Provides functions to handle status and messages in the user interface. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +// BrowserWindowTracker and PrivateBrowsingUtils are only used when opening downloaded files into a browser window +ChromeUtils.defineESModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["toolkit/downloads/downloadUI.ftl"], true) +); + +/** + * Provides functions to handle status and messages in the user interface. + */ +export var DownloadUIHelper = { + /** + * Returns an object that can be used to display prompts related to downloads. + * + * The prompts may be either anchored to a specified window, or anchored to + * the most recently active window, for example if the prompt is displayed in + * response to global notifications that are not associated with any window. + * + * @param aParent + * If specified, should reference the nsIDOMWindow to which the prompts + * should be attached. If omitted, the prompts will be attached to the + * most recently active window. + * + * @return A DownloadPrompter object. + */ + getPrompter(aParent) { + return new DownloadPrompter(aParent || null); + }, + + /** + * Open the given file as a file: URI in the active window + * + * @param nsIFile file The downloaded file + * @param options.chromeWindow Optional chrome window where we could open the file URI + * @param options.openWhere String indicating how to open the URI. + * One of "window", "tab", "tabshifted" + * @param options.isPrivate Open in private window or not + * @param options.browsingContextId BrowsingContext ID of the initiating document + * @param options.userContextId UserContextID of the initiating document + */ + loadFileIn( + file, + { + chromeWindow: browserWin, + openWhere = "tab", + isPrivate, + userContextId = 0, + browsingContextId = 0, + } = {} + ) { + let fileURI = Services.io.newFileURI(file); + let allowPrivate = + isPrivate || lazy.PrivateBrowsingUtils.permanentPrivateBrowsing; + + if ( + !browserWin || + browserWin.document.documentElement.getAttribute("windowtype") !== + "navigator:browser" + ) { + // we'll need a private window for a private download, or if we're in private-only mode + // but otherwise we want to open files in a non-private window + browserWin = lazy.BrowserWindowTracker.getTopWindow({ + private: allowPrivate, + }); + } + // if there is no suitable browser window, we'll need to open one and ignore any other `openWhere` value + // this can happen if the library dialog is the only open window + if (!browserWin) { + // There is no browser window open, so open a new one. + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let strURI = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + strURI.data = fileURI.spec; + args.appendElement(strURI); + let features = "chrome,dialog=no,all"; + if (isPrivate) { + features += ",private"; + } + browserWin = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + null, + features, + args + ); + return; + } + + // a browser window will have the helpers from utilityOverlay.js + let browsingContext = browserWin?.BrowsingContext.get(browsingContextId); + browserWin.openTrustedLinkIn(fileURI.spec, openWhere, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + private: isPrivate, + userContextId, + openerBrowser: browsingContext?.top?.embedderElement, + }); + }, +}; + +/** + * Allows displaying prompts related to downloads. + * + * @param aParent + * The nsIDOMWindow to which prompts should be attached, or null to + * attach prompts to the most recently active window. + */ +var DownloadPrompter = function (aParent) { + this._prompter = Services.ww.getNewPrompter(aParent); +}; + +DownloadPrompter.prototype = { + /** + * Constants with the different type of prompts. + */ + ON_QUIT: "prompt-on-quit", + ON_OFFLINE: "prompt-on-offline", + ON_LEAVE_PRIVATE_BROWSING: "prompt-on-leave-private-browsing", + + /** + * nsIPrompt instance for displaying messages. + */ + _prompter: null, + + /** + * Displays a warning message box that informs that the specified file is + * executable, and asks whether the user wants to launch it. + * + * @param path + * String containing the full path to the file to be opened. + * + * @resolves Boolean indicating whether the launch operation can continue. + */ + async confirmLaunchExecutable(path) { + const kPrefSkipConfirm = "browser.download.skipConfirmLaunchExecutable"; + + // Always launch in case we have no prompter implementation. + if (!this._prompter) { + return true; + } + + try { + if (Services.prefs.getBoolPref(kPrefSkipConfirm)) { + return true; + } + } catch (ex) { + // If the preference does not exist, continue with the prompt. + } + + const title = lazy.l10n.formatValueSync( + "download-ui-file-executable-security-warning-title" + ); + const message = lazy.l10n.formatValueSync( + "download-ui-file-executable-security-warning", + { executable: PathUtils.filename(path) } + ); + return this._prompter.confirm(title, message); + }, + + /** + * Displays a warning message box that informs that there are active + * downloads, and asks whether the user wants to cancel them or not. + * + * @param aDownloadsCount + * The current downloads count. + * @param aPromptType + * The type of prompt notification depending on the observer. + * + * @return False to cancel the downloads and continue, true to abort the + * operation. + */ + confirmCancelDownloads: function DP_confirmCancelDownload( + aDownloadsCount, + aPromptType + ) { + // Always continue in case we have no prompter implementation, or if there + // are no active downloads. + if (!this._prompter || aDownloadsCount <= 0) { + return false; + } + + let message, cancelButton; + + switch (aPromptType) { + case this.ON_QUIT: + message = + AppConstants.platform == "macosx" + ? "download-ui-confirm-quit-cancel-downloads-mac" + : "download-ui-confirm-quit-cancel-downloads"; + cancelButton = "download-ui-dont-quit-button"; + break; + + case this.ON_OFFLINE: + message = "download-ui-confirm-offline-cancel-downloads"; + cancelButton = "download-ui-dont-go-offline-button"; + break; + + case this.ON_LEAVE_PRIVATE_BROWSING: + message = + "download-ui-confirm-leave-private-browsing-windows-cancel-downloads"; + cancelButton = "download-ui-dont-leave-private-browsing-button"; + break; + } + + const buttonFlags = + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 + + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_1; + + let rv = this._prompter.confirmEx( + lazy.l10n.formatValueSync("download-ui-confirm-title"), + lazy.l10n.formatValueSync(message, { downloadsCount: aDownloadsCount }), + buttonFlags, + lazy.l10n.formatValueSync("download-ui-cancel-downloads-ok", { + downloadsCount: aDownloadsCount, + }), + lazy.l10n.formatValueSync(cancelButton), + null, + null, + {} + ); + return rv == 1; + }, +}; diff --git a/toolkit/components/downloads/Downloads.sys.mjs b/toolkit/components/downloads/Downloads.sys.mjs new file mode 100644 index 0000000000..1443a69e55 --- /dev/null +++ b/toolkit/components/downloads/Downloads.sys.mjs @@ -0,0 +1,294 @@ +/* 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/. */ + +/** + * Main entry point to get references to all the back-end objects. + */ + +import { Integration } from "resource://gre/modules/Integration.sys.mjs"; + +import { + Download, + DownloadError, +} from "resource://gre/modules/DownloadCore.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DownloadCombinedList: "resource://gre/modules/DownloadList.sys.mjs", + DownloadList: "resource://gre/modules/DownloadList.sys.mjs", + DownloadSummary: "resource://gre/modules/DownloadList.sys.mjs", +}); + +Integration.downloads.defineESModuleGetter( + lazy, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +/** + * This object is exposed directly to the consumers of this JavaScript module, + * and provides the only entry point to get references to back-end objects. + */ +export const Downloads = { + /** + * Work on downloads that were not started from a private browsing window. + */ + get PUBLIC() { + return "{Downloads.PUBLIC}"; + }, + /** + * Work on downloads that were started from a private browsing window. + */ + get PRIVATE() { + return "{Downloads.PRIVATE}"; + }, + /** + * Work on both Downloads.PRIVATE and Downloads.PUBLIC downloads. + */ + get ALL() { + return "{Downloads.ALL}"; + }, + + /** + * Creates a new Download object. + * + * @param properties + * Provides the initial properties for the newly created download. + * This matches the serializable representation of a Download object. + * Some of the most common properties in this object include: + * { + * source: String containing the URI for the download source. + * Alternatively, may be an nsIURI, a DownloadSource object, + * or an object with the following properties: + * { + * url: String containing the URI for the download source. + * isPrivate: Indicates whether the download originated from a + * private window. If omitted, the download is public. + * referrerInfo: String or nsIReferrerInfo object represents the + * referrerInfo of the download source. Can be + * omitted or null for example when the download + * source is not HTTP. + * cookieJarSettings: The nsICookieJarSettings object represents + * the cookieJarSetting of the download source. + * Can be omitted or null if the download source + * is not from a document. + * }, + * target: String containing the path of the target file. + * Alternatively, may be an nsIFile, a DownloadTarget object, + * or an object with the following properties: + * { + * path: String containing the path of the target file. + * }, + * saver: String representing the class of the download operation. + * If omitted, defaults to "copy". Alternatively, may be the + * serializable representation of a DownloadSaver object. + * } + * + * @return {Promise} + * @resolves The newly created Download object. + * @rejects JavaScript exception. + */ + async createDownload(properties) { + return Download.fromSerializable(properties); + }, + + /** + * Downloads data from a remote network location to a local file. + * + * This download method does not provide user interface, or the ability to + * cancel or restart the download programmatically. For that, you should + * obtain a reference to a Download object using the createDownload function. + * + * Since the download cannot be restarted, any partially downloaded data will + * not be kept in case the download fails. + * + * @param source + * String containing the URI for the download source. Alternatively, + * may be an nsIURI or a DownloadSource object. + * @param target + * String containing the path of the target file. Alternatively, may + * be an nsIFile or a DownloadTarget object. + * @param options + * An optional object used to control the behavior of this function. + * You may pass an object with a subset of the following fields: + * { + * isPrivate: Indicates whether the download originated from a + * private window. + * } + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ + async fetch(source, target, options) { + const download = await this.createDownload({ source, target }); + + if (options?.isPrivate) { + download.source.isPrivate = options.isPrivate; + } + return download.start(); + }, + + /** + * Retrieves the specified type of DownloadList object. There is one download + * list for each type, and this method always retrieves a reference to the + * same download list when called with the same argument. + * + * Calling this function may cause the list of public downloads to be reloaded + * from the previous session, if it wasn't loaded already. + * + * @param type + * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL. + * Downloads added to the Downloads.PUBLIC and Downloads.PRIVATE lists + * are reflected in the Downloads.ALL list, and downloads added to the + * Downloads.ALL list are also added to either the Downloads.PUBLIC or + * the Downloads.PRIVATE list based on their properties. + * + * @return {Promise} + * @resolves The requested DownloadList or DownloadCombinedList object. + * @rejects JavaScript exception. + */ + async getList(type) { + if (!this._promiseListsInitialized) { + this._promiseListsInitialized = (async () => { + let publicList = new lazy.DownloadList(); + let privateList = new lazy.DownloadList(); + let combinedList = new lazy.DownloadCombinedList( + publicList, + privateList + ); + + try { + await lazy.DownloadIntegration.addListObservers(publicList, false); + await lazy.DownloadIntegration.addListObservers(privateList, true); + await lazy.DownloadIntegration.initializePublicDownloadList( + publicList + ); + } catch (err) { + console.error(err); + } + + let publicSummary = await this.getSummary(Downloads.PUBLIC); + let privateSummary = await this.getSummary(Downloads.PRIVATE); + let combinedSummary = await this.getSummary(Downloads.ALL); + + await publicSummary.bindToList(publicList); + await privateSummary.bindToList(privateList); + await combinedSummary.bindToList(combinedList); + + this._lists[Downloads.PUBLIC] = publicList; + this._lists[Downloads.PRIVATE] = privateList; + this._lists[Downloads.ALL] = combinedList; + })(); + } + + await this._promiseListsInitialized; + + return this._lists[type]; + }, + + /** + * Promise resolved when the initialization of the download lists has + * completed, or null if initialization has never been requested. + */ + _promiseListsInitialized: null, + + /** + * After initialization, this object is populated with one key for each type + * of download list that can be returned (Downloads.PUBLIC, Downloads.PRIVATE, + * or Downloads.ALL). The values are the DownloadList objects. + */ + _lists: {}, + + /** + * Retrieves the specified type of DownloadSummary object. There is one + * download summary for each type, and this method always retrieves a + * reference to the same download summary when called with the same argument. + * + * Calling this function does not cause the list of public downloads to be + * reloaded from the previous session. The summary will behave as if no + * downloads are present until the getList method is called. + * + * @param type + * This can be Downloads.PUBLIC, Downloads.PRIVATE, or Downloads.ALL. + * + * @return {Promise} + * @resolves The requested DownloadList or DownloadCombinedList object. + * @rejects JavaScript exception. + */ + async getSummary(type) { + if ( + type != Downloads.PUBLIC && + type != Downloads.PRIVATE && + type != Downloads.ALL + ) { + throw new Error("Invalid type argument."); + } + + if (!(type in this._summaries)) { + this._summaries[type] = new lazy.DownloadSummary(); + } + + return this._summaries[type]; + }, + + /** + * This object is populated by the getSummary method with one key for each + * type of object that can be returned (Downloads.PUBLIC, Downloads.PRIVATE, + * or Downloads.ALL). The values are the DownloadSummary objects. + */ + _summaries: {}, + + /** + * Returns the system downloads directory asynchronously. + * Mac OSX: + * User downloads directory + * XP/2K: + * My Documents/Downloads + * Vista and others: + * User downloads directory + * Linux: + * XDG user dir spec, with a fallback to Home/Downloads + * Android: + * standard downloads directory i.e. /sdcard + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getSystemDownloadsDirectory() { + return lazy.DownloadIntegration.getSystemDownloadsDirectory(); + }, + + /** + * Returns the preferred downloads directory based on the user preferences + * in the current profile asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getPreferredDownloadsDirectory() { + return lazy.DownloadIntegration.getPreferredDownloadsDirectory(); + }, + + /** + * Returns the temporary directory where downloads are placed before the + * final location is chosen, or while the document is opened temporarily + * with an external application. This may or may not be the system temporary + * directory, based on the platform asynchronously. + * + * @return {Promise} + * @resolves The downloads directory string path. + */ + getTemporaryDownloadsDirectory() { + return lazy.DownloadIntegration.getTemporaryDownloadsDirectory(); + }, + + /** + * Constructor for a DownloadError object. When you catch an exception during + * a download, you can use this to verify if "ex instanceof Downloads.Error", + * before reading the exception properties with the error details. + */ + Error: DownloadError, +}; diff --git a/toolkit/components/downloads/components.conf b/toolkit/components/downloads/components.conf new file mode 100644 index 0000000000..63223bfe75 --- /dev/null +++ b/toolkit/components/downloads/components.conf @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{1b4c85df-cbdd-4bb6-b04e-613caece083c}', + 'contract_ids': ['@mozilla.org/transfer;1'], + 'esModule': 'resource://gre/modules/DownloadLegacy.sys.mjs', + 'constructor': 'DownloadLegacyTransfer', + }, +] diff --git a/toolkit/components/downloads/moz.build b/toolkit/components/downloads/moz.build new file mode 100644 index 0000000000..a6eea0a2d4 --- /dev/null +++ b/toolkit/components/downloads/moz.build @@ -0,0 +1,55 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +TEST_HARNESS_FILES.xpcshell.toolkit.components.downloads.test.data += [ + "test/data/empty.txt", + "test/data/source.txt", +] + +XPIDL_SOURCES += [ + "mozIDownloadPlatform.idl", +] + +XPIDL_MODULE = "downloads" + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": + CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"] + +SOURCES += [ + "DownloadPlatform.cpp", +] + +if CONFIG["OS_ARCH"] == "WINNT": + LOCAL_INCLUDES += [ + "/widget/windows", + ] + +EXTRA_JS_MODULES += [ + "DownloadCore.sys.mjs", + "DownloadIntegration.sys.mjs", + "DownloadLegacy.sys.mjs", + "DownloadList.sys.mjs", + "DownloadPaths.sys.mjs", + "Downloads.sys.mjs", + "DownloadStore.sys.mjs", + "DownloadUIHelper.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +if CONFIG["MOZ_PLACES"]: + EXTRA_JS_MODULES += [ + "DownloadHistory.sys.mjs", + ] + +FINAL_LIBRARY = "xul" + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Downloads API") diff --git a/toolkit/components/downloads/mozIDownloadPlatform.idl b/toolkit/components/downloads/mozIDownloadPlatform.idl new file mode 100644 index 0000000000..30f1cc1f24 --- /dev/null +++ b/toolkit/components/downloads/mozIDownloadPlatform.idl @@ -0,0 +1,62 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface nsIFile; + +[scriptable, uuid(9f556e4a-d9b3-46c3-9f8f-d0db1ac6c8c1)] +interface mozIDownloadPlatform : nsISupports +{ + /** + * Perform platform specific operations when a download is done. + * + * Windows: + * Add the download to the recent documents list + * Set the file to be indexed for searching + * Mac: + * Bounce the downloads dock icon + * GTK: + * Add the download to the recent documents list + * Save the source uri in the downloaded file's metadata + * Android: + * Scan media + * + * @param aSource + * Source URI of the download + * @param aReferrer + * Referrer URI of the download + * @param aTarget + * Downloaded file + * @param aContentType + * The source's content type + * @param aIsPrivate + * True for private downloads + * @return Promise that resolves once operations have completed. + */ + [implicit_jscontext] + Promise downloadDone(in nsIURI aSource, in nsIURI aReferrer, in nsIFile aTarget, + in ACString aContentType, in boolean aIsPrivate); + + /** + * Security Zone constants. Used by mapUrlToZone(). + */ + const unsigned long ZONE_MY_COMPUTER = 0; + const unsigned long ZONE_INTRANET = 1; + const unsigned long ZONE_TRUSTED = 2; + const unsigned long ZONE_INTERNET = 3; + const unsigned long ZONE_RESTRICTED = 4; + + /** + * Proxy for IInternetSecurityManager::MapUrlToZone(). + * + * Windows only. + * + * @param aURL + * URI of the download + * @return Security Zone corresponding to aURL. + */ + unsigned long mapUrlToZone(in AString aURL); +}; diff --git a/toolkit/components/downloads/test/data/empty.txt b/toolkit/components/downloads/test/data/empty.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/downloads/test/data/empty.txt diff --git a/toolkit/components/downloads/test/data/source.txt b/toolkit/components/downloads/test/data/source.txt new file mode 100644 index 0000000000..2156cb8c03 --- /dev/null +++ b/toolkit/components/downloads/test/data/source.txt @@ -0,0 +1 @@ +This test string is downloaded.
\ No newline at end of file diff --git a/toolkit/components/downloads/test/unit/common_test_Download.js b/toolkit/components/downloads/test/unit/common_test_Download.js new file mode 100644 index 0000000000..1360271686 --- /dev/null +++ b/toolkit/components/downloads/test/unit/common_test_Download.js @@ -0,0 +1,2753 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This script is loaded by "test_DownloadCore.js" and "test_DownloadLegacy.js" + * with different values of the gUseLegacySaver variable, to apply tests to both + * the "copy" and "legacy" saver implementations. + */ + +/* import-globals-from head.js */ +/* global gUseLegacySaver */ + +"use strict"; + +// Globals + +const kDeleteTempFileOnExit = "browser.helperApps.deleteTempFileOnExit"; + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +/** + * Creates and starts a new download, using either DownloadCopySaver or + * DownloadLegacySaver based on the current test run. + * + * @return {Promise} + * @resolves The newly created Download object. The download may be in progress + * or already finished. The promiseDownloadStopped function can be + * used to wait for completion. + * @rejects JavaScript exception. + */ +function promiseStartDownload(aSourceUrl) { + if (gUseLegacySaver) { + return promiseStartLegacyDownload(aSourceUrl); + } + + return promiseNewDownload(aSourceUrl).then(download => { + download.start().catch(() => {}); + return download; + }); +} + +/** + * Checks that the actual data written to disk matches the expected data as well + * as the properties of the given DownloadTarget object. + * + * @param downloadTarget + * The DownloadTarget object whose details have to be verified. + * @param expectedContents + * String containing the octets that are expected in the file. + * + * @return {Promise} + * @resolves When the properties have been verified. + * @rejects JavaScript exception. + */ +var promiseVerifyTarget = async function (downloadTarget, expectedContents) { + Assert.ok(downloadTarget.exists); + Assert.equal( + await expectNonZeroDownloadTargetSize(downloadTarget), + expectedContents.length + ); + await promiseVerifyContents(downloadTarget.path, expectedContents); +}; + +/** + * This is a temporary workaround for frequent intermittent Bug 1760112. + * For some reason the download target size is not updated, even if the code + * is "apparently" already executing and awaiting for refresh(). + * TODO(Bug 1814364): Figure out a proper fix for this. + */ +async function expectNonZeroDownloadTargetSize(downloadTarget) { + todo_check_true(downloadTarget.size, "Size should not be zero."); + if (!downloadTarget.size) { + await downloadTarget.refresh(); + } + return downloadTarget.size; +} + +/** + * Waits for an attempt to launch a file, and returns the nsIMIMEInfo used for + * the launch, or null if the file was launched with the default handler. + */ +function waitForFileLaunched() { + return new Promise(resolve => { + let waitFn = base => ({ + launchFile(file, mimeInfo) { + Integration.downloads.unregister(waitFn); + if ( + !mimeInfo || + mimeInfo.preferredAction == Ci.nsIMIMEInfo.useSystemDefault + ) { + resolve(null); + } else { + resolve(mimeInfo); + } + return Promise.resolve(); + }, + }); + Integration.downloads.register(waitFn); + }); +} + +/** + * Waits for an attempt to show the directory where a file is located, and + * returns the path of the file. + */ +function waitForDirectoryShown() { + return new Promise(resolve => { + let waitFn = base => ({ + showContainingDirectory(path) { + Integration.downloads.unregister(waitFn); + resolve(path); + return Promise.resolve(); + }, + }); + Integration.downloads.register(waitFn); + }); +} + +// Tests + +/** + * Executes a download and checks its basic properties after construction. + * The download is started by constructing the simplest Download object with + * the "copy" saver, or using the legacy nsITransfer interface. + */ +add_task(async function test_basic() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can check its basic properties before it starts. + download = await Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: { path: targetFile.path }, + saver: { type: "copy" }, + }); + + Assert.equal(download.source.url, httpUrl("source.txt")); + Assert.equal(download.target.path, targetFile.path); + + await download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we must check its basic properties while in progress. + download = await promiseStartLegacyDownload(null, { targetFile }); + + Assert.equal(download.source.url, httpUrl("source.txt")); + Assert.equal(download.target.path, targetFile.path); + + await promiseDownloadStopped(download); + } + + // Check additional properties on the finished download. + Assert.equal(download.source.referrerInfo, null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Executes a download with the tryToKeepPartialData property set, and ensures + * that the file is saved correctly. When testing DownloadLegacySaver, the + * download is executed using the nsIExternalHelperAppService component. + */ +add_task(async function test_basic_tryToKeepPartialData() { + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + continueResponses(); + await promiseDownloadStopped(download); + + // The target file should now have been created, and the ".part" file deleted. + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.equal(32, download.saver.getSha256Hash().length); +}); + +/** + * Tests that the channelIsForDownload property is set for the network request, + * and that the request is marked as throttleable. + */ +add_task(async function test_channelIsForDownload_classFlags() { + let downloadChannel = null; + + // We use a different method based on whether we are testing legacy downloads. + if (!gUseLegacySaver) { + let download = await Downloads.createDownload({ + source: { + url: httpUrl("interruptible_resumable.txt"), + async adjustChannel(channel) { + downloadChannel = channel; + }, + }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + await download.start(); + } else { + // Start a download using nsIExternalHelperAppService, but ensure it cannot + // finish before we retrieve the "request" property. + mustInterruptResponses(); + let download = await promiseStartExternalHelperAppServiceDownload(); + downloadChannel = download.saver.request; + continueResponses(); + await promiseDownloadStopped(download); + } + + Assert.ok( + downloadChannel.QueryInterface(Ci.nsIHttpChannelInternal) + .channelIsForDownload + ); + + // Throttleable is the only class flag assigned to downloads. + Assert.equal( + downloadChannel.QueryInterface(Ci.nsIClassOfService).classFlags, + Ci.nsIClassOfService.Throttleable + ); +}); + +/** + * Tests the permissions of the final target file once the download finished. + */ +add_task(async function test_unix_permissions() { + // This test is only executed on some Desktop systems. + if ( + Services.appinfo.OS != "Darwin" && + Services.appinfo.OS != "Linux" && + Services.appinfo.OS != "WINNT" + ) { + info("Skipping test."); + return; + } + + let launcherPath = getTempFile("app-launcher").path; + + for (let autoDelete of [false, true]) { + for (let isPrivate of [false, true]) { + for (let launchWhenSucceeded of [false, true]) { + info( + "Checking " + + JSON.stringify({ autoDelete, isPrivate, launchWhenSucceeded }) + ); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, autoDelete); + + let download; + if (!gUseLegacySaver) { + download = await Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launchWhenSucceeded, + launcherPath, + }); + await download.start(); + } else { + download = await promiseStartLegacyDownload(httpUrl("source.txt"), { + isPrivate, + launchWhenSucceeded, + launcherPath: launchWhenSucceeded && launcherPath, + }); + await promiseDownloadStopped(download); + } + + let isTemporary = launchWhenSucceeded && isPrivate; + let stat = await IOUtils.stat(download.target.path); + if (Services.appinfo.OS == "WINNT") { + // On Windows + // Temporary downloads should be read-only + Assert.equal(stat.permissions, isTemporary ? 0o444 : 0o666); + } else { + // On Linux, Mac + // Temporary downloads should be read-only and not accessible to other + // users, while permanently downloaded files should be readable and + // writable as specified by the system umask. + Assert.equal( + stat.permissions, + isTemporary ? 0o400 : 0o666 & ~Services.sysinfo.getProperty("umask") + ); + } + } + } + } + + // Clean up the changes to the preference. + Services.prefs.clearUserPref(kDeleteTempFileOnExit); +}); + +/** + * Tests the zone information of the final target once the download finished. + */ +add_task(async function test_windows_zoneInformation() { + // This test is only executed on Windows, and in order to work correctly it + // requires the local user applicaton data directory to be on an NTFS file + // system. We use this directory because it is more likely to be on the local + // system installation drive, while the temporary directory used by the test + // environment is on the same drive as the test sources. + if (Services.appinfo.OS != "WINNT") { + info("Skipping test."); + return; + } + + let normalTargetFile = await IOUtils.getFile( + Services.dirsvc.get("LocalAppData", Ci.nsIFile).path, + "xpcshell-download-test.txt" + ); + + // The template file name lenght is more than MAX_PATH characters. The final + // full path will be shortened to MAX_PATH length by the createUnique call. + let longTargetFile = await IOUtils.getFile( + Services.dirsvc.get("LocalAppData", Ci.nsIFile).path, + "T".repeat(256) + ".txt" + ); + longTargetFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + + const httpSourceUrl = httpUrl("source.txt"); + const dataSourceUrl = "data:text/html," + TEST_DATA_SHORT; + + function createReferrerInfo( + aReferrer, + aRefererPolicy = Ci.nsIReferrerInfo.EMPTY + ) { + return new ReferrerInfo(aRefererPolicy, true, NetUtil.newURI(aReferrer)); + } + + const tests = [ + { + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n", + }, + { + targetFile: longTargetFile, + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n", + }, + { + sourceUrl: dataSourceUrl, + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=about:internet\r\n", + }, + { + options: { + referrerInfo: createReferrerInfo( + TEST_REFERRER_URL, + Ci.nsIReferrerInfo.UNSAFE_URL + ), + }, + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\n" + + "ReferrerUrl=" + + TEST_REFERRER_URL + + "\r\n" + + "HostUrl=" + + httpSourceUrl + + "\r\n", + }, + { + options: { referrerInfo: createReferrerInfo(dataSourceUrl) }, + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=" + httpSourceUrl + "\r\n", + }, + { + options: { + referrerInfo: createReferrerInfo("http://example.com/a\rb\nc"), + }, + expectedZoneId: + "[ZoneTransfer]\r\nZoneId=3\r\n" + + "ReferrerUrl=http://example.com/\r\n" + + "HostUrl=" + + httpSourceUrl + + "\r\n", + }, + { + options: { isPrivate: true }, + expectedZoneId: "[ZoneTransfer]\r\nZoneId=3\r\n", + }, + { + options: { + referrerInfo: createReferrerInfo(TEST_REFERRER_URL), + isPrivate: true, + }, + expectedZoneId: "[ZoneTransfer]\r\nZoneId=3\r\n", + }, + ]; + for (const test of tests) { + const sourceUrl = test.sourceUrl || httpSourceUrl; + const targetFile = test.targetFile || normalTargetFile; + info(targetFile.path); + try { + if (!gUseLegacySaver) { + let download = await Downloads.createDownload({ + source: test.options + ? Object.assign({ url: sourceUrl }, test.options) + : sourceUrl, + target: targetFile.path, + }); + await download.start(); + } else { + let download = await promiseStartLegacyDownload( + sourceUrl, + Object.assign({ targetFile }, test.options || {}) + ); + await promiseDownloadStopped(download); + } + await promiseVerifyContents(targetFile.path, TEST_DATA_SHORT); + + let path = targetFile.path + ":Zone.Identifier"; + if (Services.appinfo.OS === "WINNT") { + path = PathUtils.toExtendedWindowsPath(path); + } + + Assert.equal(await IOUtils.readUTF8(path), test.expectedZoneId); + } finally { + await IOUtils.remove(targetFile.path); + } + } +}); + +/** + * Checks the referrer for downloads. + */ +add_task(async function test_referrer() { + let sourcePath = "/test_referrer.txt"; + let sourceUrl = httpUrl("test_referrer.txt"); + let dataSourceUrl = "data:text/html,<html><body></body></html>"; + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + Assert.ok(aRequest.hasHeader("Referer")); + Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL); + }); + let download; + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.UNSAFE_URL, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + + if (!gUseLegacySaver) { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let targetPath = targetFile.path; + + download = await Downloads.createDownload({ + source: { url: sourceUrl, referrerInfo }, + target: targetPath, + }); + + Assert.ok(download.source.referrerInfo.equals(referrerInfo)); + await download.start(); + + download = await Downloads.createDownload({ + source: { url: sourceUrl, referrerInfo, isPrivate: true }, + target: targetPath, + }); + Assert.ok(download.source.referrerInfo.equals(referrerInfo)); + await download.start(); + + // Test the download still works for non-HTTP channel with referrer. + download = await Downloads.createDownload({ + source: { url: dataSourceUrl, referrerInfo }, + target: targetPath, + }); + Assert.ok(download.source.referrerInfo.equals(referrerInfo)); + await download.start(); + } else { + download = await promiseStartLegacyDownload(sourceUrl, { + referrerInfo, + }); + await promiseDownloadStopped(download); + checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo); + + download = await promiseStartLegacyDownload(sourceUrl, { + referrerInfo, + isPrivate: true, + }); + await promiseDownloadStopped(download); + checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo); + + download = await promiseStartLegacyDownload(dataSourceUrl, { + referrerInfo, + }); + await promiseDownloadStopped(download); + Assert.equal(download.source.referrerInfo, null); + } + + cleanup(); +}); + +/** + * Checks the adjustChannel callback for downloads. + */ +add_task(async function test_adjustChannel() { + const sourcePath = "/test_post.txt"; + const sourceUrl = httpUrl("test_post.txt"); + const targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + const customHeader = { name: "X-Answer", value: "42" }; + const postData = "Don't Panic"; + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + gHttpServer.registerPathHandler(sourcePath, aRequest => { + Assert.equal(aRequest.method, "POST"); + + Assert.ok(aRequest.hasHeader(customHeader.name)); + Assert.equal(aRequest.getHeader(customHeader.name), customHeader.value); + + const stream = aRequest.bodyInputStream; + const body = NetUtil.readInputStreamToString(stream, stream.available()); + Assert.equal(body, postData); + }); + + function adjustChannel(channel) { + channel.QueryInterface(Ci.nsIHttpChannel); + channel.setRequestHeader(customHeader.name, customHeader.value, false); + + const stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.setData(postData, postData.length); + + channel.QueryInterface(Ci.nsIUploadChannel2); + channel.explicitSetUploadStream(stream, null, -1, "POST", false); + + return Promise.resolve(); + } + + const download = await Downloads.createDownload({ + source: { url: sourceUrl, adjustChannel }, + target: targetPath, + }); + Assert.equal(download.source.adjustChannel, adjustChannel); + Assert.equal(download.toSerializable(), null); + await download.start(); + + cleanup(); +}); + +/** + * Checks initial and final state and progress for a successful download. + */ +add_task(async function test_initial_final_state() { + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can check its state before it starts. + download = await promiseNewDownload(); + + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + Assert.equal(download.progress, 0); + Assert.ok(download.startTime === null); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + await download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we cannot check its initial state. + download = await promiseStartLegacyDownload(); + await promiseDownloadStopped(download); + } + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + Assert.equal(download.progress, 100); + Assert.ok(isValidDate(download.startTime)); + Assert.ok(download.target.exists); + Assert.equal( + await expectNonZeroDownloadTargetSize(download.target), + TEST_DATA_SHORT.length + ); +}); + +/** + * Checks the notification of the final download state. + */ +add_task(async function test_final_state_notified() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + let onchangeNotified = false; + let lastNotifiedStopped; + let lastNotifiedProgress; + download.onchange = function () { + onchangeNotified = true; + lastNotifiedStopped = download.stopped; + lastNotifiedProgress = download.progress; + }; + + // Allow the download to complete. + let promiseAttempt = download.start(); + continueResponses(); + await promiseAttempt; + + // The view should have been notified before the download completes. + Assert.ok(onchangeNotified); + Assert.ok(lastNotifiedStopped); + Assert.equal(lastNotifiedProgress, 100); +}); + +/** + * Checks intermediate progress for a successful download. + */ +add_task(async function test_intermediate_progress() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + await promiseDownloadMidway(download); + + Assert.ok(download.hasProgress); + Assert.equal(download.currentBytes, TEST_DATA_SHORT.length); + Assert.equal(download.totalBytes, TEST_DATA_SHORT.length * 2); + + // The final file size should not be computed for in-progress downloads. + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + // Continue after the first chunk of data is fully received. + continueResponses(); + await promiseDownloadStopped(download); + + Assert.ok(download.stopped); + Assert.equal(download.progress, 100); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Downloads a file with a "Content-Length" of 0 and checks the progress. + */ +add_task(async function test_empty_progress() { + let download = await promiseStartDownload(httpUrl("empty.txt")); + await promiseDownloadStopped(download); + + Assert.ok(download.stopped); + Assert.ok(download.hasProgress); + Assert.equal(download.progress, 100); + Assert.equal(download.currentBytes, 0); + Assert.equal(download.totalBytes, 0); + + // We should have received the content type even for an empty file. + Assert.equal(download.contentType, "text/plain"); + + Assert.equal((await IOUtils.stat(download.target.path)).size, 0); + Assert.ok(download.target.exists); + Assert.equal(download.target.size, 0); +}); + +/** + * Downloads a file with a "Content-Length" of 0 with the tryToKeepPartialData + * property set, and ensures that the file is saved correctly. + */ +add_task(async function test_empty_progress_tryToKeepPartialData() { + // Start a new download and configure it to keep partially downloaded data. + let download; + if (!gUseLegacySaver) { + let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path; + download = await Downloads.createDownload({ + source: httpUrl("empty.txt"), + target: { path: targetFilePath, partFilePath: targetFilePath + ".part" }, + }); + download.tryToKeepPartialData = true; + download.start().catch(() => {}); + } else { + // Start a download using nsIExternalHelperAppService, that is configured + // to keep partially downloaded data by default. + download = await promiseStartExternalHelperAppServiceDownload( + httpUrl("empty.txt") + ); + } + await promiseDownloadStopped(download); + + // The target file should now have been created, and the ".part" file deleted. + Assert.equal((await IOUtils.stat(download.target.path)).size, 0); + Assert.ok(download.target.exists); + Assert.equal(download.target.size, 0); + + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.equal(32, download.saver.getSha256Hash().length); +}); + +/** + * Downloads an empty file with no "Content-Length" and checks the progress. + */ +add_task(async function test_empty_noprogress() { + let sourcePath = "/test_empty_noprogress.txt"; + let sourceUrl = httpUrl("test_empty_noprogress.txt"); + let deferRequestReceived = Promise.withResolvers(); + + // Register an interruptible handler that notifies us when the request occurs. + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + registerInterruptibleHandler( + sourcePath, + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + deferRequestReceived.resolve(); + }, + function secondPart(aRequest, aResponse) {} + ); + + // Start the download, without allowing the request to finish. + mustInterruptResponses(); + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can hook its onchange callback that will be notified when the + // download starts. + download = await promiseNewDownload(sourceUrl); + + download.onchange = function () { + if (!download.stopped) { + Assert.ok(!download.hasProgress); + Assert.equal(download.currentBytes, 0); + Assert.equal(download.totalBytes, 0); + } + }; + + download.start().catch(() => {}); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, and it may have already made all needed property change + // notifications, thus there is no point in checking the onchange callback. + download = await promiseStartLegacyDownload(sourceUrl); + } + + // Wait for the request to be received by the HTTP server, but don't allow the + // request to finish yet. Before checking the download state, wait for the + // events to be processed by the client. + await deferRequestReceived.promise; + await promiseExecuteSoon(); + + // Check that this download has no progress report. + Assert.ok(!download.stopped); + Assert.ok(!download.hasProgress); + Assert.equal(download.currentBytes, 0); + Assert.equal(download.totalBytes, 0); + + // Now allow the response to finish. + continueResponses(); + await promiseDownloadStopped(download); + + // We should have received the content type even if no progress is reported. + Assert.equal(download.contentType, "text/plain"); + + // Verify the state of the completed download. + Assert.ok(download.stopped); + Assert.ok(!download.hasProgress); + Assert.equal(download.progress, 100); + Assert.equal(download.currentBytes, 0); + Assert.equal(download.totalBytes, 0); + Assert.ok(download.target.exists); + Assert.equal(download.target.size, 0); + + Assert.equal((await IOUtils.stat(download.target.path)).size, 0); +}); + +/** + * Calls the "start" method two times before the download is finished. + */ +add_task(async function test_start_twice() { + mustInterruptResponses(); + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can start the download later during the test. + download = await promiseNewDownload(httpUrl("interruptible.txt")); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created. Effectively, we are starting the download three times. + download = await promiseStartLegacyDownload(httpUrl("interruptible.txt")); + } + + // Call the start method two times. + let promiseAttempt1 = download.start(); + let promiseAttempt2 = download.start(); + + // Allow the download to finish. + continueResponses(); + + // Both promises should now be resolved. + await promiseAttempt1; + await promiseAttempt2; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download and verifies that its state is reported correctly. + */ +add_task(async function test_cancel_midway() { + mustInterruptResponses(); + + // In this test case, we execute different checks that are only possible with + // DownloadCopySaver or DownloadLegacySaver respectively. + let download; + let options = {}; + if (!gUseLegacySaver) { + download = await promiseNewDownload(httpUrl("interruptible.txt")); + } else { + download = await promiseStartLegacyDownload( + httpUrl("interruptible.txt"), + options + ); + } + + // Cancel the download after receiving the first part of the response. + let deferCancel = Promise.withResolvers(); + let onchange = function () { + if (!download.stopped && !download.canceled && download.progress == 50) { + // Cancel the download immediately during the notification. + deferCancel.resolve(download.cancel()); + + // The state change happens immediately after calling "cancel", but + // temporary files or part files may still exist at this point. + Assert.ok(download.canceled); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. This may happen + // when using DownloadLegacySaver. + download.onchange = onchange; + onchange(); + + let promiseAttempt; + if (!gUseLegacySaver) { + promiseAttempt = download.start(); + } + + // Wait on the promise returned by the "cancel" method to ensure that the + // cancellation process finished and temporary files were removed. + await deferCancel.promise; + + if (gUseLegacySaver) { + // The nsIWebBrowserPersist instance should have been canceled now. + Assert.equal(options.outPersist.result, Cr.NS_ERROR_ABORT); + } + + Assert.ok(download.stopped); + Assert.ok(download.canceled); + Assert.ok(download.error === null); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + Assert.equal(false, await IOUtils.exists(download.target.path)); + + // Progress properties are not reset by canceling. + Assert.equal(download.progress, 50); + Assert.equal(download.totalBytes, TEST_DATA_SHORT.length * 2); + Assert.equal(download.currentBytes, TEST_DATA_SHORT.length); + + if (!gUseLegacySaver) { + // The promise returned by "start" should have been rejected meanwhile. + try { + await promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + Assert.ok(!ex.becauseSourceFailed); + Assert.ok(!ex.becauseTargetFailed); + } + } +}); + +/** + * Cancels a download while keeping partially downloaded data, and verifies that + * both the target file and the ".part" file are deleted. + */ +add_task(async function test_cancel_midway_tryToKeepPartialData() { + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + + Assert.ok(await IOUtils.exists(download.target.path)); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + await download.cancel(); + await download.removePartialData(); + + Assert.ok(download.stopped); + Assert.ok(download.canceled); + Assert.ok(download.error === null); + + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download right after starting it. + */ +add_task(async function test_cancel_immediately() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseAttempt = download.start(); + Assert.ok(!download.stopped); + + let promiseCancel = download.cancel(); + Assert.ok(download.canceled); + + // At this point, we don't know whether the download has already stopped or + // is still waiting for cancellation. We can wait on the promise returned + // by the "start" method to know for sure. + try { + await promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + Assert.ok(!ex.becauseSourceFailed); + Assert.ok(!ex.becauseTargetFailed); + } + + Assert.ok(download.stopped); + Assert.ok(download.canceled); + Assert.ok(download.error === null); + + Assert.equal(false, await IOUtils.exists(download.target.path)); + + // Check that the promise returned by the "cancel" method has been resolved. + await promiseCancel; +}); + +/** + * Cancels and restarts a download sequentially. + */ +add_task(async function test_cancel_midway_restart() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + // The first time, cancel the download midway. + await promiseDownloadMidway(download); + await download.cancel(); + + Assert.ok(download.stopped); + + // The second time, we'll provide the entire interruptible response. + continueResponses(); + download.onchange = null; + let promiseAttempt = download.start(); + + // Download state should have already been reset. + Assert.ok(!download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + Assert.equal(download.progress, 0); + Assert.equal(download.totalBytes, 0); + Assert.equal(download.currentBytes, 0); + + await promiseAttempt; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download and restarts it from where it stopped. + */ +add_task(async function test_cancel_midway_restart_tryToKeepPartialData() { + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + await download.cancel(); + + Assert.ok(download.stopped); + Assert.ok(download.hasPartialData); + + // We should have kept the partial data and an empty target file placeholder. + Assert.ok(await IOUtils.exists(download.target.path)); + await promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + // Verify that the server sent the response from the start. + Assert.equal(gMostRecentFirstBytePos, 0); + + // The second time, we'll request and obtain the second part of the response, + // but we still stop when half of the remaining progress is reached. + let deferMidway = Promise.withResolvers(); + download.onchange = function () { + if ( + !download.stopped && + !download.canceled && + download.currentBytes == Math.floor((TEST_DATA_SHORT.length * 3) / 2) + ) { + download.onchange = null; + deferMidway.resolve(); + } + }; + + mustInterruptResponses(); + let promiseAttempt = download.start(); + + // Continue when the number of bytes we received is correct, then check that + // progress is at about 75 percent. The exact figure may vary because of + // rounding issues, since the total number of bytes in the response might not + // be a multiple of four. + await deferMidway.promise; + Assert.ok(download.progress > 72 && download.progress < 78); + + // Now we allow the download to finish. + continueResponses(); + await promiseAttempt; + + // Check that the server now sent the second part only. + Assert.equal(gMostRecentFirstBytePos, TEST_DATA_SHORT.length); + + // The target file should now have been created, and the ".part" file deleted. + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download while keeping partially downloaded data, then removes the + * data and restarts the download from the beginning. + */ +add_task(async function test_cancel_midway_restart_removePartialData() { + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + await download.cancel(); + await download.removePartialData(); + + Assert.ok(!download.hasPartialData); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + // The second time, we'll request and obtain the entire response again. + continueResponses(); + await download.start(); + + // Verify that the server sent the response from the start. + Assert.equal(gMostRecentFirstBytePos, 0); + + // The target file should now have been created, and the ".part" file deleted. + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); +}); + +/** + * Cancels a download while keeping partially downloaded data, then removes the + * data and restarts the download from the beginning without keeping the partial + * data anymore. + */ +add_task( + async function test_cancel_midway_restart_tryToKeepPartialData_false() { + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + await download.cancel(); + + download.tryToKeepPartialData = false; + + // The above property change does not affect existing partial data. + Assert.ok(download.hasPartialData); + await promiseVerifyContents(download.target.partFilePath, TEST_DATA_SHORT); + + await download.removePartialData(); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + + // Restart the download from the beginning. + mustInterruptResponses(); + download.start().catch(() => {}); + + await promiseDownloadMidway(download); + await promisePartFileReady(download); + + // While the download is in progress, we should still have a ".part" file. + Assert.ok(!download.hasPartialData); + Assert.ok(await IOUtils.exists(download.target.path)); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + // On Unix, verify that the file with the partially downloaded data is not + // accessible by other users on the system. + if (Services.appinfo.OS == "Darwin" || Services.appinfo.OS == "Linux") { + Assert.equal( + (await IOUtils.stat(download.target.partFilePath)).permissions, + 0o600 + ); + } + + await download.cancel(); + + // The ".part" file should be deleted now that the download is canceled. + Assert.ok(!download.hasPartialData); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + + // The third time, we'll request and obtain the entire response again. + continueResponses(); + await download.start(); + + // Verify that the server sent the response from the start. + Assert.equal(gMostRecentFirstBytePos, 0); + + // The target file should now have been created, and the ".part" file deleted. + await promiseVerifyTarget( + download.target, + TEST_DATA_SHORT + TEST_DATA_SHORT + ); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + } +); + +/** + * Cancels a download right after starting it, then restarts it immediately. + */ +add_task(async function test_cancel_immediately_restart_immediately() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + let promiseAttempt = download.start(); + + Assert.ok(!download.stopped); + + download.cancel(); + Assert.ok(download.canceled); + + let promiseRestarted = download.start(); + Assert.ok(!download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + Assert.equal(download.hasProgress, false); + Assert.equal(download.progress, 0); + Assert.equal(download.totalBytes, 0); + Assert.equal(download.currentBytes, 0); + + // Ensure the next request is now allowed to complete, regardless of whether + // the canceled request was received by the server or not. + continueResponses(); + try { + await promiseAttempt; + // If we get here, it means that the first attempt actually succeeded. In + // fact, this could be a valid outcome, because the cancellation request may + // not have been processed in time before the download finished. + info("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + Assert.ok(!ex.becauseSourceFailed); + Assert.ok(!ex.becauseTargetFailed); + } + + await promiseRestarted; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Cancels a download midway, then restarts it immediately. + */ +add_task(async function test_cancel_midway_restart_immediately() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + let promiseAttempt = download.start(); + + // The first time, cancel the download midway. + await promiseDownloadMidway(download); + download.cancel(); + Assert.ok(download.canceled); + + let promiseRestarted = download.start(); + Assert.ok(!download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + // For the following test, we rely on the network layer reporting its progress + // asynchronously. Otherwise, there is nothing stopping the restarted + // download from reaching the same progress as the first request already. + Assert.equal(download.hasProgress, false); + Assert.equal(download.progress, 0); + Assert.equal(download.totalBytes, 0); + Assert.equal(download.currentBytes, 0); + + // The second request is allowed to complete. + continueResponses(); + try { + await promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + Assert.ok(!ex.becauseSourceFailed); + Assert.ok(!ex.becauseTargetFailed); + } + + await promiseRestarted; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Calls the "cancel" method on a successful download. + */ +add_task(async function test_cancel_successful() { + let download = await promiseStartDownload(); + await promiseDownloadStopped(download); + + // The cancel method should succeed with no effect. + await download.cancel(); + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Calls the "cancel" method two times in a row. + */ +add_task(async function test_cancel_twice() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseAttempt = download.start(); + Assert.ok(!download.stopped); + + let promiseCancel1 = download.cancel(); + Assert.ok(download.canceled); + let promiseCancel2 = download.cancel(); + + try { + await promiseAttempt; + do_throw("The download should have been canceled."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + Assert.ok(!ex.becauseSourceFailed); + Assert.ok(!ex.becauseTargetFailed); + } + + // Both promises should now be resolved. + await promiseCancel1; + await promiseCancel2; + + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(download.canceled); + Assert.ok(download.error === null); + + Assert.equal(false, await IOUtils.exists(download.target.path)); +}); + +/** + * Checks the "refresh" method for succeeded downloads. + */ +add_task(async function test_refresh_succeeded() { + let download = await promiseStartDownload(); + await promiseDownloadStopped(download); + + // The DownloadTarget properties should be the same after calling "refresh". + await download.refresh(); + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); + + // If the file is removed, only the "exists" property should change, and the + // "size" property should keep its previous value. + await IOUtils.move(download.target.path, `${download.target.path}.old`); + await download.refresh(); + Assert.ok(!download.target.exists); + Assert.equal( + await expectNonZeroDownloadTargetSize(download.target), + TEST_DATA_SHORT.length + ); + + // The DownloadTarget properties should be restored when the file is put back. + await IOUtils.move(`${download.target.path}.old`, download.target.path); + await download.refresh(); + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Checks that a download cannot be restarted after the "finalize" method. + */ +add_task(async function test_finalize() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible.txt")); + + let promiseFinalized = download.finalize(); + + try { + await download.start(); + do_throw("It should not be possible to restart after finalization."); + } catch (ex) {} + + await promiseFinalized; + + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(download.canceled); + Assert.ok(download.error === null); + + Assert.equal(false, await IOUtils.exists(download.target.path)); +}); + +/** + * Checks that the "finalize" method can remove partially downloaded data. + */ +add_task(async function test_finalize_tryToKeepPartialData() { + // Check finalization without removing partial data. + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + await download.finalize(); + + Assert.ok(download.hasPartialData); + Assert.ok(await IOUtils.exists(download.target.path)); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + // Clean up. + await download.removePartialData(); + + // Check finalization while removing partial data. + download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + await download.finalize(true); + + Assert.ok(!download.hasPartialData); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); +}); + +/** + * Checks that whenSucceeded returns a promise that is resolved after a restart. + */ +add_task(async function test_whenSucceeded_after_restart() { + mustInterruptResponses(); + + let promiseSucceeded; + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can verify getting a reference before the first download attempt. + download = await promiseNewDownload(httpUrl("interruptible.txt")); + promiseSucceeded = download.whenSucceeded(); + download.start().catch(() => {}); + } else { + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we cannot get the reference before the first attempt. + download = await promiseStartLegacyDownload(httpUrl("interruptible.txt")); + promiseSucceeded = download.whenSucceeded(); + } + + // Cancel the first download attempt. + await download.cancel(); + + // The second request is allowed to complete. + continueResponses(); + download.start().catch(() => {}); + + // Wait for the download to finish by waiting on the whenSucceeded promise. + await promiseSucceeded; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); +}); + +/** + * Ensures download error details are reported on network failures. + */ +add_task(async function test_error_source() { + let serverSocket = startFakeServer(); + try { + let sourceUrl = "http://localhost:" + serverSocket.port + "/source.txt"; + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = await promiseNewDownload(sourceUrl); + + Assert.ok(download.error === null); + + await 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 = await promiseStartLegacyDownload(sourceUrl); + await promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + // A specific error object is thrown when reading from the source fails. + } + + // Check the properties now that the download stopped. + Assert.ok(download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error !== null); + Assert.ok(download.error.becauseSourceFailed); + Assert.ok(!download.error.becauseTargetFailed); + + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + } finally { + serverSocket.close(); + } +}); + +/** + * Ensures a download error is reported when receiving less bytes than what was + * specified in the Content-Length header. + */ +add_task(async function test_error_source_partial() { + let sourceUrl = httpUrl("shorter-than-content-length-http-1-1.txt"); + + let enforcePref = Services.prefs.getBoolPref( + "network.http.enforce-framing.http1" + ); + Services.prefs.setBoolPref("network.http.enforce-framing.http1", true); + + function cleanup() { + Services.prefs.setBoolPref( + "network.http.enforce-framing.http1", + enforcePref + ); + } + registerCleanupFunction(cleanup); + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = await promiseNewDownload(sourceUrl); + + Assert.ok(download.error === null); + + await 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 = await promiseStartLegacyDownload(sourceUrl); + await promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + // A specific error object is thrown when reading from the source fails. + } + + // Check the properties now that the download stopped. + Assert.ok(download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error !== null); + Assert.ok(download.error.becauseSourceFailed); + Assert.ok(!download.error.becauseTargetFailed); + Assert.equal(download.error.result, Cr.NS_ERROR_NET_PARTIAL_TRANSFER); + + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); +}); + +/** + * Ensures a download error is reported when an RST packet is received. + */ +add_task(async function test_error_source_netreset() { + if (AppConstants.platform == "win") { + return; + } + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = await promiseNewDownload(httpUrl("netreset.txt")); + + Assert.ok(download.error === null); + + await 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 = await promiseStartLegacyDownload(httpUrl("netreset.txt")); + await promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseSourceFailed) { + throw ex; + } + // A specific error object is thrown when reading from the source fails. + } + + // Check the properties now that the download stopped. + Assert.ok(download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error !== null); + Assert.ok(download.error.becauseSourceFailed); + Assert.ok(!download.error.becauseTargetFailed); + Assert.equal(download.error.result, Cr.NS_ERROR_NET_RESET); + + Assert.equal(false, await IOUtils.exists(download.target.path)); +}); + +/** + * Ensures download error details are reported on local writing failures. + */ +add_task(async function test_error_target() { + // Create a file without write access permissions before downloading. + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0); + try { + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: targetFile, + }); + await 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 = await promiseStartLegacyDownload(null, { targetFile }); + await promiseDownloadStopped(download); + } + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) { + throw ex; + } + // A specific error object is thrown when writing to the target fails. + } + + // Check the properties now that the download stopped. + Assert.ok(download.stopped); + Assert.ok(!download.canceled); + Assert.ok(download.error !== null); + Assert.ok(download.error.becauseTargetFailed); + Assert.ok(!download.error.becauseSourceFailed); + + // Check unserializing a download with an errorObj and restarting it will + // clear the errorObj initially. + let serializable = download.toSerializable(); + Assert.ok(serializable.errorObj, "Ensure we have an errorObj initially"); + let reserialized = JSON.parse(JSON.stringify(serializable)); + download = await Downloads.createDownload(reserialized); + let promise = download.start().catch(() => {}); + serializable = download.toSerializable(); + Assert.ok(!serializable.errorObj, "Ensure we didn't persist the errorObj"); + await promise; + } finally { + // Restore the default permissions to allow deleting the file on Windows. + if (targetFile.exists()) { + targetFile.permissions = FileUtils.PERMS_FILE; + targetFile.remove(false); + } + } +}); + +/** + * Restarts a failed download. + */ +add_task(async function test_error_restart() { + let download; + + // Create a file without write access permissions before downloading. + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0); + try { + // Use DownloadCopySaver or DownloadLegacySaver based on the test run, + // specifying the target file we created. + if (!gUseLegacySaver) { + download = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: targetFile, + }); + download.start().catch(() => {}); + } else { + download = await promiseStartLegacyDownload(null, { targetFile }); + } + await promiseDownloadStopped(download); + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseTargetFailed) { + throw ex; + } + // A specific error object is thrown when writing to the target fails. + } finally { + // Restore the default permissions to allow deleting the file on Windows. + if (targetFile.exists()) { + targetFile.permissions = FileUtils.PERMS_FILE; + + // Also for Windows, rename the file before deleting. This makes the + // current file name available immediately for a new file, while deleting + // in place prevents creation of a file with the same name for some time. + targetFile.moveTo(null, targetFile.leafName + ".delete.tmp"); + targetFile.remove(false); + } + } + + // Restart the download and wait for completion. + await download.start(); + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + Assert.ok(download.error === null); + Assert.equal(download.progress, 100); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Executes download in both public and private modes. + */ +add_task(async function test_public_and_private() { + let sourcePath = "/test_public_and_private.txt"; + let sourceUrl = httpUrl("test_public_and_private.txt"); + let testCount = 0; + + // Apply pref to allow all cookies. + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + + function cleanup() { + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); + Services.cookies.removeAll(); + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + if (testCount == 0) { + // No cookies should exist for first public download. + Assert.ok(!aRequest.hasHeader("Cookie")); + aResponse.setHeader("Set-Cookie", "foobar=1", false); + testCount++; + } else if (testCount == 1) { + // The cookie should exists for second public download. + Assert.ok(aRequest.hasHeader("Cookie")); + Assert.equal(aRequest.getHeader("Cookie"), "foobar=1"); + testCount++; + } else if (testCount == 2) { + // No cookies should exist for first private download. + Assert.ok(!aRequest.hasHeader("Cookie")); + } + }); + + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + await Downloads.fetch(sourceUrl, targetFile); + await Downloads.fetch(sourceUrl, targetFile); + + if (!gUseLegacySaver) { + let download = await Downloads.createDownload({ + source: { url: sourceUrl, isPrivate: true }, + target: targetFile, + }); + await download.start(); + } else { + let download = await promiseStartLegacyDownload(sourceUrl, { + isPrivate: true, + }); + await promiseDownloadStopped(download); + } + + cleanup(); +}); + +/** + * Checks the startTime gets updated even after a restart. + */ +add_task(async function test_cancel_immediately_restart_and_check_startTime() { + let download = await promiseStartDownload(); + + let startTime = download.startTime; + Assert.ok(isValidDate(download.startTime)); + + await download.cancel(); + Assert.equal(download.startTime.getTime(), startTime.getTime()); + + // Wait for a timeout. + await promiseTimeout(10); + + await download.start(); + Assert.ok(download.startTime.getTime() > startTime.getTime()); +}); + +/** + * Executes download with content-encoding. + */ +add_task(async function test_with_content_encoding() { + let sourcePath = "/test_with_content_encoding.txt"; + let sourceUrl = httpUrl("test_with_content_encoding.txt"); + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT_GZIP_ENCODED.length, + false + ); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED); + }); + + let download = await promiseStartDownload(sourceUrl); + await promiseDownloadStopped(download); + + Assert.equal(download.progress, 100); + Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + + // Ensure the content matches the decoded test data. + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); + + cleanup(); +}); + +/** + * Checks that the file is not decoded if the extension matches the encoding. + */ +add_task(async function test_with_content_encoding_ignore_extension() { + let sourcePath = "/test_with_content_encoding_ignore_extension.gz"; + let sourceUrl = httpUrl("test_with_content_encoding_ignore_extension.gz"); + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + gHttpServer.registerPathHandler(sourcePath, function (aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT_GZIP_ENCODED.length, + false + ); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED); + }); + + let download = await promiseStartDownload(sourceUrl); + await promiseDownloadStopped(download); + + Assert.equal(download.progress, 100); + Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + Assert.equal( + await expectNonZeroDownloadTargetSize(download.target), + TEST_DATA_SHORT_GZIP_ENCODED.length + ); + + // Ensure the content matches the encoded test data. We convert the data to a + // string before executing the content check. + await promiseVerifyTarget( + download.target, + String.fromCharCode.apply(String, TEST_DATA_SHORT_GZIP_ENCODED) + ); + + cleanup(); +}); + +/** + * Cancels and restarts a download sequentially with content-encoding. + */ +add_task(async function test_cancel_midway_restart_with_content_encoding() { + mustInterruptResponses(); + + let download = await promiseStartDownload(httpUrl("interruptible_gzip.txt")); + + // The first time, cancel the download midway. + await new Promise(resolve => { + let onchange = function () { + if ( + !download.stopped && + !download.canceled && + download.currentBytes == TEST_DATA_SHORT_GZIP_ENCODED_FIRST.length + ) { + resolve(download.cancel()); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + download.onchange = onchange; + onchange(); + }); + + Assert.ok(download.stopped); + + // The second time, we'll provide the entire interruptible response. + continueResponses(); + download.onchange = null; + await download.start(); + + Assert.equal(download.progress, 100); + Assert.equal(download.totalBytes, TEST_DATA_SHORT_GZIP_ENCODED.length); + + await promiseVerifyTarget(download.target, TEST_DATA_SHORT); +}); + +/** + * Download with parental controls enabled. + */ +add_task(async function test_blocked_parental_controls() { + let blockFn = base => ({ + shouldBlockForParentalControls: () => Promise.resolve(true), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + registerCleanupFunction(cleanup); + + let download; + try { + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we want to check that the promise + // returned by the "start" method is rejected. + download = await promiseNewDownload(); + await 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 = await promiseStartLegacyDownload(); + await promiseDownloadStopped(download); + } + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + Assert.ok(ex.becauseBlockedByParentalControls); + Assert.ok(download.error.becauseBlockedByParentalControls); + } + + // Now that the download stopped, the target file should not exist. + Assert.equal(false, await IOUtils.exists(download.target.path)); + + cleanup(); +}); + +/** + * Test a download that will be blocked by Windows parental controls by + * resulting in an HTTP status code of 450. + */ +add_task(async function test_blocked_parental_controls_httpstatus450() { + let download; + try { + if (!gUseLegacySaver) { + download = await promiseNewDownload(httpUrl("parentalblocked.zip")); + await download.start(); + } else { + download = await promiseStartLegacyDownload( + httpUrl("parentalblocked.zip") + ); + await promiseDownloadStopped(download); + } + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + Assert.ok(ex.becauseBlockedByParentalControls); + Assert.ok(download.error.becauseBlockedByParentalControls); + Assert.ok(download.stopped); + } + + Assert.equal(false, await IOUtils.exists(download.target.path)); +}); + +/** + * Check that DownloadCopySaver can always retrieve the hash. + * DownloadLegacySaver can only retrieve the hash when + * nsIExternalHelperAppService is invoked. + */ +add_task(async function test_getSha256Hash() { + if (!gUseLegacySaver) { + let download = await promiseStartDownload(httpUrl("source.txt")); + await promiseDownloadStopped(download); + Assert.ok(download.stopped); + Assert.equal(32, download.saver.getSha256Hash().length); + } +}); + +/** + * Checks that application reputation blocks the download and the target file + * does not exist. + */ +add_task(async function test_blocked_applicationReputation() { + let download = await promiseBlockedDownload({ + keepPartialData: false, + keepBlockedData: false, + }); + + // Now that the download is blocked, the target file should not exist. + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); + + // There should also be no blocked data in this case + Assert.ok(!download.hasBlockedData); +}); + +/** + * Checks that if a download restarts while processing an application reputation + * request, the status is handled correctly. + */ +add_task(async function test_blocked_applicationReputation_race() { + let isFirstShouldBlockCall = true; + + let blockFn = base => ({ + shouldBlockForReputationCheck(download) { + if (isFirstShouldBlockCall) { + isFirstShouldBlockCall = false; + + // 2. Cancel and restart the download before the first attempt has a + // chance to finish. + download.cancel(); + download.removePartialData(); + download.start(); + + // 3. Allow the first attempt to finish with a blocked response. + return Promise.resolve({ + shouldBlock: true, + verdict: Downloads.Error.BLOCK_VERDICT_UNCOMMON, + }); + } + + // 4/5. Don't block the download the second time. The race condition would + // occur with the first attempt regardless of whether the second one + // is blocked, but not blocking here makes the test simpler. + return Promise.resolve({ + shouldBlock: false, + verdict: "", + }); + }, + shouldKeepBlockedData: () => Promise.resolve(true), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + registerCleanupFunction(cleanup); + + let download; + + try { + // 1. Start the download and get a reference to the promise asociated with + // the first attempt, before allowing the response to continue. + download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + let firstAttempt = promiseDownloadStopped(download); + continueResponses(); + + // 4/5. Wait for the first attempt to be completed. The result of this + // should appear as a cancellation. + await firstAttempt; + + do_throw("The first attempt should have been canceled."); + } catch (ex) { + // The "becauseBlocked" property should be false. + if (!(ex instanceof Downloads.Error) || ex.becauseBlocked) { + throw ex; + } + } + + // 6. Wait for the second attempt to be completed. + await promiseDownloadStopped(download); + + // 7. At this point, "hasBlockedData" should be false. + Assert.ok(!download.hasBlockedData); + + cleanup(); +}); + +/** + * Checks that application reputation blocks the download but maintains the + * blocked data, which will be deleted when the block is confirmed. + */ +add_task(async function test_blocked_applicationReputation_confirmBlock() { + let download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + Assert.ok(download.hasBlockedData); + Assert.equal((await IOUtils.stat(download.target.path)).size, 0); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + await download.confirmBlock(); + + // After confirming the block the download should be in a failed state and + // have no downloaded data left on disk. + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(!download.hasBlockedData); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); +}); + +/** + * Checks that application reputation blocks the download but maintains the + * blocked data, which will be used to complete the download when unblocking. + */ +add_task(async function test_blocked_applicationReputation_unblock() { + let download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + useLegacySaver: gUseLegacySaver, + }); + + Assert.ok(download.hasBlockedData); + Assert.equal((await IOUtils.stat(download.target.path)).size, 0); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + await download.unblock(); + + // After unblocking the download should have succeeded and be + // present at the final path. + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.hasBlockedData); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + await promiseVerifyTarget(download.target, TEST_DATA_SHORT + TEST_DATA_SHORT); + + // The only indication the download was previously blocked is the + // existence of the error, so we make sure it's still set. + Assert.ok(download.error instanceof Downloads.Error); + Assert.ok(download.error.becauseBlocked); + Assert.ok(download.error.becauseBlockedByReputationCheck); +}); + +/** + * Check that calling cancel on a blocked download will not cause errors + */ +add_task(async function test_blocked_applicationReputation_cancel() { + let download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + // This call should succeed on a blocked download. + await download.cancel(); + + // Calling cancel should not have changed the current state, the download + // should still be blocked. + Assert.ok(download.error.becauseBlockedByReputationCheck); + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(download.hasBlockedData); +}); + +/** + * Checks that unblock and confirmBlock cannot race on a blocked download + */ +add_task(async function test_blocked_applicationReputation_decisionRace() { + let download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + let unblockPromise = download.unblock(); + let confirmBlockPromise = download.confirmBlock(); + + await confirmBlockPromise.then( + () => { + do_throw("confirmBlock should have failed."); + }, + () => {} + ); + + await unblockPromise; + + // After unblocking the download should have succeeded and be + // present at the final path. + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.hasBlockedData); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.ok(await IOUtils.exists(download.target.path)); + + download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + confirmBlockPromise = download.confirmBlock(); + unblockPromise = download.unblock(); + + await unblockPromise.then( + () => { + do_throw("unblock should have failed."); + }, + () => {} + ); + + await confirmBlockPromise; + + // After confirming the block the download should be in a failed state and + // have no downloaded data left on disk. + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(!download.hasBlockedData); + Assert.equal(false, await IOUtils.exists(download.target.partFilePath)); + Assert.equal(false, await IOUtils.exists(download.target.path)); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); +}); + +/** + * Checks that unblocking a blocked download fails if the blocked data has been + * removed. + */ +add_task(async function test_blocked_applicationReputation_unblock() { + let download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + Assert.ok(download.hasBlockedData); + Assert.ok(await IOUtils.exists(download.target.partFilePath)); + + // Remove the blocked data without telling the download. + await IOUtils.remove(download.target.partFilePath); + + let unblockPromise = download.unblock(); + await 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. + Assert.ok(!download.hasBlockedData); + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + Assert.ok(!download.target.exists); + Assert.equal(download.target.size, 0); +}); + +/** + * download.showContainingDirectory() action + */ +add_task(async function test_showContainingDirectory() { + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + + let download = await Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: "", + }); + + let promiseDirectoryShown = waitForDirectoryShown(); + await download.showContainingDirectory(); + let path = await promiseDirectoryShown; + try { + new FileUtils.File(path); + do_throw("Should have failed because of an invalid path."); + } catch (ex) { + if (!(ex instanceof Components.Exception)) { + throw ex; + } + // Invalid paths on Windows are reported with NS_ERROR_FAILURE, + // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux + let validResult = + ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH || + ex.result == Cr.NS_ERROR_FAILURE; + Assert.ok(validResult); + } + + download = await Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: targetPath, + }); + + promiseDirectoryShown = waitForDirectoryShown(); + download.showContainingDirectory(); + await promiseDirectoryShown; +}); + +/** + * download.launch() action + */ +add_task(async function test_launch() { + let customLauncher = getTempFile("app-launcher"); + + // Test both with and without setting a custom application. + for (let launcherPath of [null, customLauncher.path]) { + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can test that file is not launched if download.succeeded is not set. + download = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launcherPath, + launchWhenSucceeded: true, + }); + + try { + await download.launch(); + do_throw("Can't launch download file as it has not completed yet"); + } catch (ex) { + Assert.equal( + ex.message, + "launch can only be called if the download succeeded" + ); + } + + Assert.ok(download.launchWhenSucceeded); + await download.start(); + } else { + // When testing DownloadLegacySaver, the download is already started when + // it is created, thus we don't test calling "launch" before starting. + download = await promiseStartLegacyDownload(httpUrl("source.txt"), { + launcherPath, + launchWhenSucceeded: true, + }); + Assert.ok(download.launchWhenSucceeded); + await promiseDownloadStopped(download); + } + + let promiseFileLaunched = waitForFileLaunched(); + download.launch(); + let result = await promiseFileLaunched; + + // Verify that the results match the test case. + if (!launcherPath) { + // This indicates that the default handler has been chosen. + Assert.ok(result === null); + } else { + // Check the nsIMIMEInfo instance that would have been used for launching. + Assert.equal(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp); + Assert.ok( + result.preferredApplicationHandler + .QueryInterface(Ci.nsILocalHandlerApp) + .executable.equals(customLauncher) + ); + } + } +}); + +/** + * Test passing an invalid path as the launcherPath property. + */ +add_task(async function test_launcherPath_invalid() { + let download = await Downloads.createDownload({ + source: { url: httpUrl("source.txt") }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + launcherPath: " ", + }); + + let promiseDownloadLaunched = new Promise(resolve => { + let waitFn = base => { + let launchOverride = { + launchDownload() { + Integration.downloads.unregister(waitFn); + let superPromise = super.launchDownload(...arguments); + resolve(superPromise); + return superPromise; + }, + }; + Object.setPrototypeOf(launchOverride, base); + return launchOverride; + }; + Integration.downloads.register(waitFn); + }); + + await download.start(); + try { + download.launch(); + await promiseDownloadLaunched; + do_throw("Can't launch file with invalid custom launcher"); + } catch (ex) { + if (!(ex instanceof Components.Exception)) { + throw ex; + } + // Invalid paths on Windows are reported with NS_ERROR_FAILURE, + // but with NS_ERROR_FILE_UNRECOGNIZED_PATH on Mac/Linux + let validResult = + ex.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH || + ex.result == Cr.NS_ERROR_FAILURE; + Assert.ok(validResult); + } +}); + +/** + * Tests that download.launch() is automatically called after + * the download finishes if download.launchWhenSucceeded = true + */ +add_task(async function test_launchWhenSucceeded() { + let customLauncher = getTempFile("app-launcher"); + + // Test both with and without setting a custom application. + for (let launcherPath of [null, customLauncher.path]) { + let promiseFileLaunched = waitForFileLaunched(); + + if (!gUseLegacySaver) { + let download = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME).path, + launchWhenSucceeded: true, + launcherPath, + }); + await download.start(); + } else { + let download = await promiseStartLegacyDownload(httpUrl("source.txt"), { + launcherPath, + launchWhenSucceeded: true, + }); + await promiseDownloadStopped(download); + } + + let result = await promiseFileLaunched; + + // Verify that the results match the test case. + if (!launcherPath) { + // This indicates that the default handler has been chosen. + Assert.ok(result === null); + } else { + // Check the nsIMIMEInfo instance that would have been used for launching. + Assert.equal(result.preferredAction, Ci.nsIMIMEInfo.useHelperApp); + Assert.ok( + result.preferredApplicationHandler + .QueryInterface(Ci.nsILocalHandlerApp) + .executable.equals(customLauncher) + ); + } + } +}); + +/** + * Tests that the proper content type is set for a normal download. + */ +add_task(async function test_contentType() { + let download = await promiseStartDownload(httpUrl("source.txt")); + await promiseDownloadStopped(download); + + Assert.equal("text/plain", download.contentType); +}); + +/** + * Tests that the serialization/deserialization of the startTime Date + * object works correctly. + */ +add_task(async function test_toSerializable_startTime() { + let download1 = await promiseStartDownload(httpUrl("source.txt")); + await promiseDownloadStopped(download1); + + let serializable = download1.toSerializable(); + let reserialized = JSON.parse(JSON.stringify(serializable)); + + let download2 = await Downloads.createDownload(reserialized); + + Assert.equal(download1.startTime.constructor.name, "Date"); + Assert.equal(download2.startTime.constructor.name, "Date"); + Assert.equal(download1.startTime.toJSON(), download2.startTime.toJSON()); +}); + +/** + * Checks that downloads are added to browsing history when they start. + */ +add_task(async function test_history() { + mustInterruptResponses(); + + let sourceUrl = httpUrl("interruptible.txt"); + + // We will wait for the visit to be notified during the download. + await PlacesUtils.history.clear(); + let promiseVisit = promiseWaitForVisit(sourceUrl); + + // Start a download that is not allowed to finish yet. + let download = await promiseStartDownload(sourceUrl); + let expectedFile = new FileUtils.File(download.target.path); + let expectedFileURI = Services.io.newFileURI(expectedFile); + let promiseAnnotation = waitForAnnotation( + sourceUrl, + "downloads/destinationFileURI", + expectedFileURI.spec + ); + + // The history and annotation notifications should be received before the download completes. + let [time, transitionType, lastKnownTitle] = await promiseVisit; + await promiseAnnotation; + + Assert.equal(time, download.startTime.getTime()); + Assert.equal(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD); + Assert.equal(lastKnownTitle, expectedFile.leafName); + + let pageInfo = await PlacesUtils.history.fetch(sourceUrl, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get("downloads/destinationFileURI"), + expectedFileURI.spec, + "Should have saved the correct download target annotation." + ); + + // Restart and complete the download after clearing history. + await PlacesUtils.history.clear(); + download.cancel(); + continueResponses(); + await download.start(); + + // The restart should not have added a new history visit. + Assert.equal(false, await PlacesUtils.history.hasVisits(sourceUrl)); +}); + +/** + * Checks that downloads started by nsIHelperAppService are added to the + * browsing history when they start. + */ +add_task(async function test_history_tryToKeepPartialData() { + // We will wait for the visit to be notified during the download. + await PlacesUtils.history.clear(); + let promiseVisit = promiseWaitForVisit( + httpUrl("interruptible_resumable.txt") + ); + + // Start a download that is not allowed to finish yet. + let beforeStartTimeMs = Date.now(); + let download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver: gUseLegacySaver, + }); + + // The history notifications should be received before the download completes. + let [time, transitionType] = await promiseVisit; + Assert.equal(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD); + + // The time set by nsIHelperAppService may be different than the start time in + // the download object, thus we only check that it is a meaningful time. Note + // that we subtract one second from the earliest time to account for rounding. + Assert.ok(time >= beforeStartTimeMs - 1000); + + // Complete the download before finishing the test. + continueResponses(); + await promiseDownloadStopped(download); +}); + +/** + * Checks that finished downloads are not removed. + */ +add_task(async function test_download_cancel_retry_finalize() { + // Start a download that is not allowed to finish yet. + let sourceUrl = httpUrl("interruptible.txt"); + let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path; + mustInterruptResponses(); + let download1 = await Downloads.createDownload({ + source: sourceUrl, + target: { path: targetFilePath, partFilePath: targetFilePath + ".part" }, + }); + download1.start().catch(() => {}); + await promiseDownloadMidway(download1); + await promisePartFileReady(download1); + + // Cancel the download and make sure that the partial data do not exist. + await download1.cancel(); + Assert.equal(targetFilePath, download1.target.path); + Assert.equal(false, await IOUtils.exists(download1.target.path)); + Assert.equal(false, await IOUtils.exists(download1.target.partFilePath)); + continueResponses(); + + // Download the same file again with a different download session. + let download2 = await Downloads.createDownload({ + source: sourceUrl, + target: { path: targetFilePath, partFilePath: targetFilePath + ".part" }, + }); + download2.start().catch(() => {}); + + // Wait for download to be completed. + await promiseDownloadStopped(download2); + Assert.equal(targetFilePath, download2.target.path); + Assert.ok(await IOUtils.exists(download2.target.path)); + Assert.equal(false, await IOUtils.exists(download2.target.partFilePath)); + + // Finalize the first download session. + await download1.finalize(true); + + // The complete download should not have been removed. + Assert.ok(await IOUtils.exists(download2.target.path)); + Assert.equal(false, await IOUtils.exists(download2.target.partFilePath)); +}); + +/** + * Checks that confirmBlock does not clobber unrelated safe files. + */ +add_task(async function test_blocked_removeByHand_confirmBlock() { + let download1 = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + }); + + Assert.ok(download1.hasBlockedData); + Assert.equal((await IOUtils.stat(download1.target.path)).size, 0); + Assert.ok(await IOUtils.exists(download1.target.partFilePath)); + + // Remove the placeholder without telling the download. + await IOUtils.remove(download1.target.path); + Assert.equal(false, await IOUtils.exists(download1.target.path)); + + // Download a file with the same name as the blocked download. + let download2 = await Downloads.createDownload({ + source: httpUrl("interruptible_resumable.txt"), + target: { + path: download1.target.path, + partFilePath: download1.target.path + ".part", + }, + }); + download2.start().catch(() => {}); + + // Wait for download to be completed. + await promiseDownloadStopped(download2); + Assert.equal(download1.target.path, download2.target.path); + Assert.ok(await IOUtils.exists(download2.target.path)); + + // Remove the blocked download. + await download1.confirmBlock(); + + // After confirming the complete download should not have been removed. + Assert.ok(await IOUtils.exists(download2.target.path)); +}); + +/** + * Tests that the temp download files are removed on exit and exiting private + * mode after they have been launched. + */ +add_task(async function test_launchWhenSucceeded_deleteTempFileOnExit() { + let customLauncherPath = getTempFile("app-launcher").path; + let autoDeleteTargetPathOne = getTempFile(TEST_TARGET_FILE_NAME).path; + let autoDeleteTargetPathTwo = getTempFile(TEST_TARGET_FILE_NAME).path; + let noAutoDeleteTargetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + + let autoDeleteDownloadOne = await Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: autoDeleteTargetPathOne, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + await autoDeleteDownloadOne.start(); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, true); + let autoDeleteDownloadTwo = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: autoDeleteTargetPathTwo, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + await autoDeleteDownloadTwo.start(); + + Services.prefs.setBoolPref(kDeleteTempFileOnExit, false); + let noAutoDeleteDownload = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: noAutoDeleteTargetPath, + launchWhenSucceeded: true, + launcherPath: customLauncherPath, + }); + await noAutoDeleteDownload.start(); + + Services.prefs.clearUserPref(kDeleteTempFileOnExit); + + Assert.ok(await IOUtils.exists(autoDeleteTargetPathOne)); + Assert.ok(await IOUtils.exists(autoDeleteTargetPathTwo)); + Assert.ok(await IOUtils.exists(noAutoDeleteTargetPath)); + + // Simulate leaving private browsing mode + Services.obs.notifyObservers(null, "last-pb-context-exited"); + Assert.equal(false, await IOUtils.exists(autoDeleteTargetPathOne)); + + // Simulate browser shutdown + let expire = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsIObserver); + expire.observe(null, "profile-before-change", null); + + // The file should still exist following the simulated shutdown. + Assert.ok(await IOUtils.exists(autoDeleteTargetPathTwo)); + Assert.ok(await IOUtils.exists(noAutoDeleteTargetPath)); +}); + +add_task(async function test_partitionKey() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + Services.prefs.setBoolPref("privacy.partition.network_state", true); + + function promiseVerifyDownloadChannel(url, partitionKey) { + return TestUtils.topicObserved( + "http-on-modify-request", + (subject, data) => { + let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); + if (httpChannel.URI.spec != url) { + return false; + } + + let reqLoadInfo = httpChannel.loadInfo; + let cookieJarSettings = reqLoadInfo.cookieJarSettings; + + // Check the partitionKey of the cookieJarSettings. + Assert.equal(cookieJarSettings.partitionKey, partitionKey); + + return true; + } + ); + } + + let test_url = httpUrl("source.txt"); + let uri = Services.io.newURI(test_url); + let cookieJarSettings = Cc["@mozilla.org/cookieJarSettings;1"].createInstance( + Ci.nsICookieJarSettings + ); + cookieJarSettings.initWithURI(uri, false); + let expectedPartitionKey = cookieJarSettings.partitionKey; + + let verifyPromise; + + let download; + if (!gUseLegacySaver) { + // When testing DownloadCopySaver, we have control over the download, thus + // we can check its basic properties before it starts. + download = await Downloads.createDownload({ + source: { url: test_url, cookieJarSettings }, + target: { path: targetFile.path }, + saver: { type: "copy" }, + }); + + Assert.equal(download.source.url, test_url); + Assert.equal(download.target.path, targetFile.path); + + verifyPromise = promiseVerifyDownloadChannel( + test_url, + expectedPartitionKey + ); + + await download.start(); + } else { + verifyPromise = promiseVerifyDownloadChannel( + test_url, + expectedPartitionKey + ); + + // When testing DownloadLegacySaver, the download is already started when it + // is created, thus we must check its basic properties while in progress. + download = await promiseStartLegacyDownload(null, { + targetFile, + cookieJarSettings, + }); + + Assert.equal(download.source.url, test_url); + Assert.equal(download.target.path, targetFile.path); + + await promiseDownloadStopped(download); + } + + await verifyPromise; + + Services.prefs.clearUserPref("privacy.partition.network_state"); +}); diff --git a/toolkit/components/downloads/test/unit/head.js b/toolkit/components/downloads/test/unit/head.js new file mode 100644 index 0000000000..19d85b6f7c --- /dev/null +++ b/toolkit/components/downloads/test/unit/head.js @@ -0,0 +1,1183 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Provides infrastructure for automated download components tests. + */ + +"use strict"; + +var { Integration } = ChromeUtils.importESModule( + "resource://gre/modules/Integration.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + Downloads: "resource://gre/modules/Downloads.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gExternalHelperAppService", + "@mozilla.org/uriloader/external-helper-app-service;1", + Ci.nsIExternalHelperAppService +); + +/* global DownloadIntegration */ +Integration.downloads.defineESModuleGetter( + this, + "DownloadIntegration", + "resource://gre/modules/DownloadIntegration.sys.mjs" +); + +const ServerSocket = Components.Constructor( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init" +); +const BinaryOutputStream = Components.Constructor( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +const ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" +); + +const TEST_TARGET_FILE_NAME = "test-download.txt"; +const TEST_STORE_FILE_NAME = "test-downloads.json"; + +// We are testing an HTTPS referrer with HTTP downloads in order to verify that +// the default policy will not prevent the referrer from being passed around. +const TEST_REFERRER_URL = "https://www.example.com/referrer.html"; + +const TEST_DATA_SHORT = "This test string is downloaded."; +// Generate using gzipCompressString in TelemetryController.sys.mjs. +const TEST_DATA_SHORT_GZIP_ENCODED_FIRST = [ + 31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 11, 201, 200, 44, 86, 40, 73, 45, 46, 81, 40, + 46, 41, 202, 204, +]; +const TEST_DATA_SHORT_GZIP_ENCODED_SECOND = [ + 75, 87, 0, 114, 83, 242, 203, 243, 114, 242, 19, 83, 82, 83, 244, 0, 151, 222, + 109, 43, 31, 0, 0, 0, +]; +const TEST_DATA_SHORT_GZIP_ENCODED = TEST_DATA_SHORT_GZIP_ENCODED_FIRST.concat( + TEST_DATA_SHORT_GZIP_ENCODED_SECOND +); + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} + +// Support functions + +/** + * HttpServer object initialized before tests start. + */ +var gHttpServer; + +/** + * Given a file name, returns a string containing an URI that points to the file + * on the currently running instance of the test HTTP server. + */ +function httpUrl(aFileName) { + return ( + "http://www.example.com:" + + gHttpServer.identity.primaryPort + + "/" + + aFileName + ); +} + +/** + * Returns a reference to a temporary file that is guaranteed not to exist and + * is cleaned up later. See FileTestUtils.getTempFile for details. + */ +function getTempFile(leafName) { + return FileTestUtils.getTempFile(leafName); +} + +/** + * Check for file existence. + * @param {string} path The file path. + */ +async function fileExists(path) { + try { + return (await IOUtils.stat(path)).type == "regular"; + } catch (ex) { + return false; + } +} + +/** + * Waits for pending events to be processed. + * + * @return {Promise} + * @resolves When pending events have been processed. + * @rejects Never. + */ +function promiseExecuteSoon() { + return new Promise(resolve => { + executeSoon(resolve); + }); +} + +/** + * Waits for a pending events to be processed after a timeout. + * + * @return {Promise} + * @resolves When pending events have been processed. + * @rejects Never. + */ +function promiseTimeout(aTime) { + return new Promise(resolve => { + do_timeout(aTime, resolve); + }); +} + +/** + * Waits for a new history visit to be notified for the specified URI. + * + * @param aUrl + * String containing the URI that will be visited. + * + * @return {Promise} + * @resolves Array [aTime, aTransitionType] from page-visited places event. + * @rejects Never. + */ +function promiseWaitForVisit(aUrl) { + return new Promise(resolve => { + function listener(aEvents) { + Assert.equal(aEvents.length, 1); + let event = aEvents[0]; + Assert.equal(event.type, "page-visited"); + if (event.url == aUrl) { + PlacesObservers.removeListener(["page-visited"], listener); + resolve([event.visitTime, event.transitionType, event.lastKnownTitle]); + } + } + PlacesObservers.addListener(["page-visited"], listener); + }); +} + +/** + * Creates a new Download object, setting a temporary file as the target. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * + * @return {Promise} + * @resolves The newly created Download object. + * @rejects JavaScript exception. + */ +function promiseNewDownload(aSourceUrl) { + return Downloads.createDownload({ + source: aSourceUrl || httpUrl("source.txt"), + target: getTempFile(TEST_TARGET_FILE_NAME), + }); +} + +/** + * Starts a new download using the nsIWebBrowserPersist interface, and controls + * it using the legacy nsITransfer interface. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * @param aOptions + * An optional object used to control the behavior of this function. + * You may pass an object with a subset of the following fields: + * { + * isPrivate: Boolean indicating whether the download originated from a + * private window. + * referrerInfo: referrerInfo for the download source. + * cookieJarSettings: cookieJarSettings for the download source. + * targetFile: nsIFile for the target, or null to use a temporary file. + * outPersist: Receives a reference to the created nsIWebBrowserPersist + * instance. + * launchWhenSucceeded: Boolean indicating whether the target should + * be launched when it has completed successfully. + * launcherPath: String containing the path of the custom executable to + * use to launch the target of the download. + * } + * + * @return {Promise} + * @resolves The Download object created as a consequence of controlling the + * download through the legacy nsITransfer interface. + * @rejects Never. The current test fails in case of exceptions. + */ +function promiseStartLegacyDownload(aSourceUrl, aOptions) { + let sourceURI = NetUtil.newURI(aSourceUrl || httpUrl("source.txt")); + let targetFile = + (aOptions && aOptions.targetFile) || getTempFile(TEST_TARGET_FILE_NAME); + + let persist = Cc[ + "@mozilla.org/embedding/browser/nsWebBrowserPersist;1" + ].createInstance(Ci.nsIWebBrowserPersist); + if (aOptions) { + aOptions.outPersist = persist; + } + + let fileExtension = null, + mimeInfo = null; + let match = sourceURI.pathQueryRef.match(/\.([^.\/]+)$/); + if (match) { + fileExtension = match[1]; + } + + if (fileExtension) { + try { + mimeInfo = gMIMEService.getFromTypeAndExtension(null, fileExtension); + mimeInfo.preferredAction = Ci.nsIMIMEInfo.saveToDisk; + } catch (ex) {} + } + + if (aOptions && aOptions.launcherPath) { + Assert.ok(mimeInfo != null); + + let localHandlerApp = Cc[ + "@mozilla.org/uriloader/local-handler-app;1" + ].createInstance(Ci.nsILocalHandlerApp); + localHandlerApp.executable = new FileUtils.File(aOptions.launcherPath); + + mimeInfo.preferredApplicationHandler = localHandlerApp; + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + } + + if (aOptions && aOptions.launchWhenSucceeded) { + Assert.ok(mimeInfo != null); + + mimeInfo.preferredAction = Ci.nsIMIMEInfo.useHelperApp; + } + + // Apply decoding if required by the "Content-Encoding" header. + persist.persistFlags &= ~Ci.nsIWebBrowserPersist.PERSIST_FLAGS_NO_CONVERSION; + persist.persistFlags |= + Ci.nsIWebBrowserPersist.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION; + + let transfer = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer); + + return new Promise(resolve => { + Downloads.getList(Downloads.ALL) + .then(function (aList) { + // Temporarily register a view that will get notified when the download we + // are controlling becomes visible in the list of downloads. + aList + .addView({ + onDownloadAdded(aDownload) { + aList.removeView(this).catch(do_report_unexpected_exception); + + // Remove the download to keep the list empty for the next test. This + // also allows the caller to register the "onchange" event directly. + let promise = aList.remove(aDownload); + + // When the download object is ready, make it available to the caller. + promise.then( + () => resolve(aDownload), + do_report_unexpected_exception + ); + }, + }) + .catch(do_report_unexpected_exception); + + let isPrivate = aOptions && aOptions.isPrivate; + let referrerInfo = aOptions ? aOptions.referrerInfo : null; + let cookieJarSettings = aOptions ? aOptions.cookieJarSettings : null; + let classification = + aOptions?.downloadClassification ?? + Ci.nsITransfer.DOWNLOAD_ACCEPTABLE; + // Initialize the components so they reference each other. This will cause + // the Download object to be created and added to the public downloads. + transfer.init( + sourceURI, + null, + NetUtil.newURI(targetFile), + null, + mimeInfo, + null, + null, + persist, + isPrivate, + classification, + null + ); + persist.progressListener = transfer; + + // Start the actual download process. + persist.saveURI( + sourceURI, + Services.scriptSecurityManager.getSystemPrincipal(), + 0, + referrerInfo, + cookieJarSettings, + null, + null, + targetFile, + Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, + isPrivate + ); + }) + .catch(do_report_unexpected_exception); + }); +} + +/** + * Starts a new download using the nsIHelperAppService interface, and controls + * it using the legacy nsITransfer interface. The source of the download will + * be "interruptible_resumable.txt" and partially downloaded data will be kept. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("interruptible_resumable.txt"). + * + * @return {Promise} + * @resolves The Download object created as a consequence of controlling the + * download through the legacy nsITransfer interface. + * @rejects Never. The current test fails in case of exceptions. + */ +function promiseStartExternalHelperAppServiceDownload(aSourceUrl) { + let sourceURI = NetUtil.newURI( + aSourceUrl || httpUrl("interruptible_resumable.txt") + ); + + return new Promise(resolve => { + Downloads.getList(Downloads.PUBLIC) + .then(function (aList) { + // Temporarily register a view that will get notified when the download we + // are controlling becomes visible in the list of downloads. + aList + .addView({ + onDownloadAdded(aDownload) { + aList.removeView(this).catch(do_report_unexpected_exception); + + // Remove the download to keep the list empty for the next test. This + // also allows the caller to register the "onchange" event directly. + let promise = aList.remove(aDownload); + + // When the download object is ready, make it available to the caller. + promise.then( + () => resolve(aDownload), + do_report_unexpected_exception + ); + }, + }) + .catch(do_report_unexpected_exception); + + let channel = NetUtil.newChannel({ + uri: sourceURI, + loadUsingSystemPrincipal: true, + }); + + // Start the actual download process. + channel.asyncOpen({ + contentListener: null, + + onStartRequest(aRequest) { + let requestChannel = aRequest.QueryInterface(Ci.nsIChannel); + this.contentListener = gExternalHelperAppService.doContent( + requestChannel.contentType, + aRequest, + null, + true + ); + this.contentListener.onStartRequest(aRequest); + }, + + onStopRequest(aRequest, aStatusCode) { + this.contentListener.onStopRequest(aRequest, aStatusCode); + }, + + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + this.contentListener.onDataAvailable( + aRequest, + aInputStream, + aOffset, + aCount + ); + }, + }); + }) + .catch(do_report_unexpected_exception); + }); +} + +/** + * Waits for a download to reach half of its progress, in case it has not + * reached the expected progress already. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download has reached half of its progress. + * @rejects Never. + */ +function promiseDownloadMidway(aDownload) { + return new Promise(resolve => { + // Wait for the download to reach half of its progress. + let onchange = function () { + if ( + !aDownload.stopped && + !aDownload.canceled && + aDownload.progress == 50 + ) { + aDownload.onchange = null; + resolve(); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + aDownload.onchange = onchange; + onchange(); + }); +} + +/** + * Waits for a download to make any amount of progress. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download has transfered any number of bytes. + * @rejects Never. + */ +function promiseDownloadStarted(aDownload) { + return new Promise(resolve => { + // Wait for the download to transfer some amount of bytes. + let onchange = function () { + if ( + !aDownload.stopped && + !aDownload.canceled && + aDownload.currentBytes > 0 + ) { + aDownload.onchange = null; + resolve(); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + aDownload.onchange = onchange; + onchange(); + }); +} + +/** + * Waits for a download to finish. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download succeeded or errored. + * @rejects Never. + */ +function promiseDownloadFinished(aDownload) { + return new Promise(resolve => { + // Wait for the download to finish. + let onchange = function () { + if (aDownload.succeeded || aDownload.error) { + aDownload.onchange = null; + resolve(); + } + }; + + // Register for the notification, but also call the function directly in + // case the download already reached the expected progress. + aDownload.onchange = onchange; + onchange(); + }); +} + +/** + * Waits for a download to finish, in case it has not finished already. + * + * @param aDownload + * The Download object to wait upon. + * + * @return {Promise} + * @resolves When the download has finished successfully. + * @rejects JavaScript exception if the download failed. + */ +function promiseDownloadStopped(aDownload) { + if (!aDownload.stopped) { + // The download is in progress, wait for the current attempt to finish and + // report any errors that may occur. + return aDownload.start(); + } + + if (aDownload.succeeded) { + return Promise.resolve(); + } + + // The download failed or was canceled. + return Promise.reject(aDownload.error || new Error("Download canceled.")); +} + +/** + * Returns a new public or private DownloadList object. + * + * @param aIsPrivate + * True for the private list, false or undefined for the public list. + * + * @return {Promise} + * @resolves The newly created DownloadList object. + * @rejects JavaScript exception. + */ +function promiseNewList(aIsPrivate) { + // We need to clear all the internal state for the list and summary objects, + // since all the objects are interdependent internally. + Downloads._promiseListsInitialized = null; + Downloads._lists = {}; + Downloads._summaries = {}; + + return Downloads.getList(aIsPrivate ? Downloads.PRIVATE : Downloads.PUBLIC); +} + +/** + * Ensures that the given file contents are equal to the given string. + * + * @param aPath + * String containing the path of the file whose contents should be + * verified. + * @param aExpectedContents + * String containing the octets that are expected in the file. + * + * @return {Promise} + * @resolves When the operation completes. + * @rejects Never. + */ +async function promiseVerifyContents(aPath, aExpectedContents) { + let file = new FileUtils.File(aPath); + + if (!(await IOUtils.exists(aPath))) { + do_throw("File does not exist: " + aPath); + } + + if ((await IOUtils.stat(aPath)).size == 0) { + do_throw("File is empty: " + aPath); + } + + await new Promise(resolve => { + NetUtil.asyncFetch( + { uri: NetUtil.newURI(file), loadUsingSystemPrincipal: true }, + function (aInputStream, aStatus) { + Assert.ok(Components.isSuccessCode(aStatus)); + let contents = NetUtil.readInputStreamToString( + aInputStream, + aInputStream.available() + ); + if ( + contents.length > TEST_DATA_SHORT.length * 2 || + /[^\x20-\x7E]/.test(contents) + ) { + // Do not print the entire content string to the test log. + Assert.equal(contents.length, aExpectedContents.length); + Assert.ok(contents == aExpectedContents); + } else { + // Print the string if it is short and made of printable characters. + Assert.equal(contents, aExpectedContents); + } + resolve(); + } + ); + }); +} + +/** + * Creates and starts a new download, configured to keep partial data, and + * returns only when the first part of "interruptible_resumable.txt" has been + * saved to disk. You must call "continueResponses" to allow the interruptible + * request to continue. + * + * This function uses either DownloadCopySaver or DownloadLegacySaver based on + * the current test run. + * + * @param aOptions + * An optional object used to control the behavior of this function. + * You may pass an object with a subset of the following fields: + * { + * useLegacySaver: Boolean indicating whether to launch a legacy download. + * } + * + * @return {Promise} + * @resolves The newly created Download object, still in progress. + * @rejects JavaScript exception. + */ +async function promiseStartDownload_tryToKeepPartialData({ + useLegacySaver = false, +} = {}) { + mustInterruptResponses(); + + // Start a new download and configure it to keep partially downloaded data. + let download; + if (!useLegacySaver) { + let targetFilePath = getTempFile(TEST_TARGET_FILE_NAME).path; + download = await Downloads.createDownload({ + source: httpUrl("interruptible_resumable.txt"), + target: { + path: targetFilePath, + partFilePath: targetFilePath + ".part", + }, + }); + download.tryToKeepPartialData = true; + download.start().catch(() => {}); + } else { + // Start a download using nsIExternalHelperAppService, that is configured + // to keep partially downloaded data by default. + download = await promiseStartExternalHelperAppServiceDownload(); + } + + await promiseDownloadMidway(download); + await promisePartFileReady(download); + + return download; +} + +/** + * This function should be called after the progress notification for a download + * is received, and waits for the worker thread of BackgroundFileSaver to + * receive the data to be written to the ".part" file on disk. + * + * @return {Promise} + * @resolves When the ".part" file has been written to disk. + * @rejects JavaScript exception. + */ +async function promisePartFileReady(aDownload) { + // We don't have control over the file output code in BackgroundFileSaver. + // After we receive the download progress notification, we may only check + // that the ".part" file has been created, while its size cannot be + // determined because the file is currently open. + try { + do { + await promiseTimeout(50); + } while (!(await IOUtils.exists(aDownload.target.partFilePath))); + } catch (ex) { + if (!(ex instanceof IOUtils.Error)) { + throw ex; + } + // This indicates that the file has been created and cannot be accessed. + // The specific error might vary with the platform. + info("Expected exception while checking existence: " + ex.toString()); + // Wait some more time to allow the write to complete. + await promiseTimeout(100); + } +} + +/** + * Create a download which will be reputation blocked. + * + * @param options + * { + * keepPartialData: bool, + * keepBlockedData: bool, + * useLegacySaver: bool, + * verdict: string indicating the detailed reason for the block, + * } + * @return {Promise} + * @resolves The reputation blocked download. + * @rejects JavaScript exception. + */ +async function promiseBlockedDownload({ + keepPartialData, + keepBlockedData, + useLegacySaver, + verdict = Downloads.Error.BLOCK_VERDICT_UNCOMMON, +} = {}) { + let blockFn = base => ({ + shouldBlockForReputationCheck: () => + Promise.resolve({ + shouldBlock: true, + verdict, + }), + shouldKeepBlockedData: () => Promise.resolve(keepBlockedData), + }); + + Integration.downloads.register(blockFn); + function cleanup() { + Integration.downloads.unregister(blockFn); + } + registerCleanupFunction(cleanup); + + let download; + + try { + if (keepPartialData) { + download = await promiseStartDownload_tryToKeepPartialData({ + useLegacySaver, + }); + continueResponses(); + } else if (useLegacySaver) { + download = await promiseStartLegacyDownload(); + } else { + download = await promiseNewDownload(); + await download.start(); + do_throw("The download should have blocked."); + } + + await promiseDownloadStopped(download); + do_throw("The download should have blocked."); + } catch (ex) { + if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) { + throw ex; + } + Assert.ok(ex.becauseBlockedByReputationCheck); + Assert.equal(ex.reputationCheckVerdict, verdict); + Assert.ok(download.error.becauseBlockedByReputationCheck); + Assert.equal(download.error.reputationCheckVerdict, verdict); + } + + Assert.ok(download.stopped); + Assert.ok(!download.succeeded); + + cleanup(); + return download; +} + +/** + * Starts a socket listener that closes each incoming connection. + * + * @returns nsIServerSocket that listens for connections. Call its "close" + * method to stop listening and free the server port. + */ +function startFakeServer() { + let serverSocket = new ServerSocket(-1, true, -1); + serverSocket.asyncListen({ + onSocketAccepted(aServ, aTransport) { + aTransport.close(Cr.NS_BINDING_ABORTED); + }, + onStopListening() {}, + }); + return serverSocket; +} + +/** + * This is an internal reference that should not be used directly by tests. + */ +var _gDeferResponses = Promise.withResolvers(); + +/** + * Ensures that all the interruptible requests started after this function is + * called won't complete until the continueResponses function is called. + * + * Normally, the internal HTTP server returns all the available data as soon as + * a request is received. In order for some requests to be served one part at a + * time, special interruptible handlers are registered on the HTTP server. This + * allows testing events or actions that need to happen in the middle of a + * download. + * + * For example, the handler accessible at the httpUri("interruptible.txt") + * address returns the TEST_DATA_SHORT text, then it may block until the + * continueResponses method is called. At this point, the handler sends the + * TEST_DATA_SHORT text again to complete the response. + * + * If an interruptible request is started before the function is called, it may + * or may not be blocked depending on the actual sequence of events. + */ +function mustInterruptResponses() { + // If there are pending blocked requests, allow them to complete. This is + // done to prevent requests from being blocked forever, but should not affect + // the test logic, since previously started requests should not be monitored + // on the client side anymore. + _gDeferResponses.resolve(); + + info("Interruptible responses will be blocked midway."); + _gDeferResponses = Promise.withResolvers(); +} + +/** + * Allows all the current and future interruptible requests to complete. + */ +function continueResponses() { + info("Interruptible responses are now allowed to continue."); + _gDeferResponses.resolve(); +} + +/** + * Registers an interruptible response handler. + * + * @param aPath + * Path passed to nsIHttpServer.registerPathHandler. + * @param aFirstPartFn + * This function is called when the response is received, with the + * aRequest and aResponse arguments of the server. + * @param aSecondPartFn + * This function is called with the aRequest and aResponse arguments of + * the server, when the continueResponses function is called. + */ +function registerInterruptibleHandler(aPath, aFirstPartFn, aSecondPartFn) { + gHttpServer.registerPathHandler(aPath, function (aRequest, aResponse) { + info("Interruptible request started."); + + // Process the first part of the response. + aResponse.processAsync(); + aFirstPartFn(aRequest, aResponse); + + // Wait on the current deferred object, then finish the request. + _gDeferResponses.promise + .then(function RIH_onSuccess() { + aSecondPartFn(aRequest, aResponse); + aResponse.finish(); + info("Interruptible request finished."); + }) + .catch(console.error); + }); +} + +/** + * Ensure the given date object is valid. + * + * @param aDate + * The date object to be checked. This value can be null. + */ +function isValidDate(aDate) { + return aDate && aDate.getTime && !isNaN(aDate.getTime()); +} + +/** + * Check actual ReferrerInfo is the same as expected. + * Because the actual download's referrer info's computedReferrer is computed + * from referrerPolicy and originalReferrer and is non-null, and the expected + * referrer info was constructed in isolation and therefore the computedReferrer + * is null, it isn't possible to use equals here. */ +function checkEqualReferrerInfos(aActualInfo, aExpectedInfo) { + Assert.equal( + !!aExpectedInfo.originalReferrer, + !!aActualInfo.originalReferrer + ); + if (aExpectedInfo.originalReferrer && aActualInfo.originalReferrer) { + Assert.equal( + aExpectedInfo.originalReferrer.spec, + aActualInfo.originalReferrer.spec + ); + } + + Assert.equal(aExpectedInfo.sendReferrer, aActualInfo.sendReferrer); + Assert.equal(aExpectedInfo.referrerPolicy, aActualInfo.referrerPolicy); +} + +/** + * Waits for the download annotations to be set for the given page, required + * because the addDownload method will add these to the database asynchronously. + */ +function waitForAnnotation(sourceUriSpec, annotationName, optionalValue) { + return TestUtils.waitForCondition(async () => { + let pageInfo = await PlacesUtils.history.fetch(sourceUriSpec, { + includeAnnotations: true, + }); + if (optionalValue) { + return pageInfo?.annotations.get(annotationName) == optionalValue; + } + return pageInfo?.annotations.has(annotationName); + }, `Should have found annotation ${annotationName} for ${sourceUriSpec}`); +} + +/** + * Position of the first byte served by the "interruptible_resumable.txt" + * handler during the most recent response. + */ +var gMostRecentFirstBytePos; + +// Initialization functions common to all tests + +add_setup(function test_common_initialize() { + // Start the HTTP server. + gHttpServer = new HttpServer(); + gHttpServer.registerDirectory("/", do_get_file("../data")); + gHttpServer.start(-1); + registerCleanupFunction(() => { + return new Promise(resolve => { + // Ensure all the pending HTTP requests have a chance to finish. + continueResponses(); + // Stop the HTTP server, calling resolve when it's done. + gHttpServer.stop(resolve); + }); + }); + + // Serve the downloads from a domain located in the Internet zone on Windows. + gHttpServer.identity.setPrimary( + "http", + "www.example.com", + gHttpServer.identity.primaryPort + ); + Services.prefs.setCharPref("network.dns.localDomains", "www.example.com"); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("network.dns.localDomains"); + }); + + // Cache locks might prevent concurrent requests to the same resource, and + // this may block tests that use the interruptible handlers. + Services.prefs.setBoolPref("browser.cache.disk.enable", false); + Services.prefs.setBoolPref("browser.cache.memory.enable", false); + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.cache.disk.enable"); + Services.prefs.clearUserPref("browser.cache.memory.enable"); + }); + + // Allow relaxing default referrer. + Services.prefs.setBoolPref( + "network.http.referer.disallowCrossSiteRelaxingDefault", + false + ); + registerCleanupFunction(function () { + Services.prefs.clearUserPref( + "network.http.referer.disallowCrossSiteRelaxingDefault" + ); + }); + + registerInterruptibleHandler( + "/interruptible.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT.length * 2, + false + ); + aResponse.write(TEST_DATA_SHORT); + }, + function secondPart(aRequest, aResponse) { + aResponse.write(TEST_DATA_SHORT); + } + ); + + registerInterruptibleHandler( + "/interruptible_nosize.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.write(TEST_DATA_SHORT); + }, + function secondPart(aRequest, aResponse) { + aResponse.write(TEST_DATA_SHORT); + } + ); + + registerInterruptibleHandler( + "/interruptible_resumable.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + + // Determine if only part of the data should be sent. + let data = TEST_DATA_SHORT + TEST_DATA_SHORT; + if (aRequest.hasHeader("Range")) { + var matches = aRequest + .getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + var firstBytePos = matches[1] === undefined ? 0 : matches[1]; + var lastBytePos = + matches[2] === undefined ? data.length - 1 : matches[2]; + if (firstBytePos >= data.length) { + aResponse.setStatusLine( + aRequest.httpVersion, + 416, + "Requested Range Not Satisfiable" + ); + aResponse.setHeader("Content-Range", "*/" + data.length, false); + aResponse.finish(); + return; + } + + aResponse.setStatusLine(aRequest.httpVersion, 206, "Partial Content"); + aResponse.setHeader( + "Content-Range", + firstBytePos + "-" + lastBytePos + "/" + data.length, + false + ); + + data = data.substring(firstBytePos, lastBytePos + 1); + + gMostRecentFirstBytePos = firstBytePos; + } else { + gMostRecentFirstBytePos = 0; + } + + aResponse.setHeader("Content-Length", "" + data.length, false); + + aResponse.write(data.substring(0, data.length / 2)); + + // Store the second part of the data on the response object, so that it + // can be used by the secondPart function. + aResponse.secondPartData = data.substring(data.length / 2); + }, + function secondPart(aRequest, aResponse) { + aResponse.write(aResponse.secondPartData); + } + ); + + registerInterruptibleHandler( + "/interruptible_gzip.txt", + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Encoding", "gzip", false); + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT_GZIP_ENCODED.length + ); + + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_FIRST); + }, + function secondPart(aRequest, aResponse) { + let bos = new BinaryOutputStream(aResponse.bodyOutputStream); + bos.writeByteArray(TEST_DATA_SHORT_GZIP_ENCODED_SECOND); + } + ); + + gHttpServer.registerPathHandler( + "/shorter-than-content-length-http-1-1.txt", + function (aRequest, aResponse) { + aResponse.processAsync(); + aResponse.setStatusLine("1.1", 200, "OK"); + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT.length * 2, + false + ); + aResponse.write(TEST_DATA_SHORT); + aResponse.finish(); + } + ); + + gHttpServer.registerPathHandler("/busy.txt", function (aRequest, aResponse) { + aResponse.setStatusLine("1.1", 504, "Gateway Timeout"); + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.setHeader("Content-Length", "" + TEST_DATA_SHORT.length, false); + aResponse.write(TEST_DATA_SHORT); + }); + + gHttpServer.registerPathHandler("/redirect", function (aRequest, aResponse) { + aResponse.setStatusLine("1.1", 301, "Moved Permanently"); + aResponse.setHeader("Location", httpUrl("busy.txt"), false); + aResponse.setHeader("Content-Type", "text/javascript", false); + aResponse.setHeader("Content-Length", "0", false); + }); + + // This URL will emulate being blocked by Windows Parental controls + gHttpServer.registerPathHandler( + "/parentalblocked.zip", + function (aRequest, aResponse) { + aResponse.setStatusLine( + aRequest.httpVersion, + 450, + "Blocked by Windows Parental Controls" + ); + } + ); + + // This URL sends some data followed by an RST packet + gHttpServer.registerPathHandler( + "/netreset.txt", + function (aRequest, aResponse) { + info("Starting response that will be aborted."); + aResponse.processAsync(); + aResponse.setHeader("Content-Type", "text/plain", false); + aResponse.write(TEST_DATA_SHORT); + promiseExecuteSoon() + .then(() => { + aResponse.abort(null, true); + aResponse.finish(); + info("Aborting response with network reset."); + }) + .then(null, console.error); + } + ); + + // During unit tests, most of the functions that require profile access or + // operating system features will be disabled. Individual tests may override + // them again to check for specific behaviors. + Integration.downloads.register(base => { + let override = { + loadPublicDownloadListFromStore: () => Promise.resolve(), + shouldKeepBlockedData: () => Promise.resolve(false), + shouldBlockForParentalControls: () => Promise.resolve(false), + shouldBlockForReputationCheck: () => + Promise.resolve({ + shouldBlock: false, + verdict: "", + }), + confirmLaunchExecutable: () => Promise.resolve(), + launchFile: () => Promise.resolve(), + showContainingDirectory: () => Promise.resolve(), + // This flag allows re-enabling the default observers during their tests. + allowObservers: false, + addListObservers() { + return this.allowObservers + ? super.addListObservers(...arguments) + : Promise.resolve(); + }, + // This flag allows re-enabling the download directory logic for its tests. + _allowDirectories: false, + set allowDirectories(value) { + this._allowDirectories = value; + // We have to invalidate the previously computed directory path. + this._downloadsDirectory = null; + }, + _getDirectory(name) { + return super._getDirectory(this._allowDirectories ? name : "TmpD"); + }, + }; + Object.setPrototypeOf(override, base); + return override; + }); + + // Make sure that downloads started using nsIExternalHelperAppService are + // saved to disk without asking for a destination interactively. + let mock = { + QueryInterface: ChromeUtils.generateQI(["nsIHelperAppLauncherDialog"]), + promptForSaveToFileAsync( + aLauncher, + aWindowContext, + aDefaultFileName, + aSuggestedFileExtension, + aForcePrompt + ) { + // The dialog should create the empty placeholder file. + let file = getTempFile(TEST_TARGET_FILE_NAME); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + aLauncher.saveDestinationAvailable(file); + }, + }; + + let cid = MockRegistrar.register( + "@mozilla.org/helperapplauncherdialog;1", + mock + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(cid); + }); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js b/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js new file mode 100644 index 0000000000..2484e591a4 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadBlockedTelemetry.js @@ -0,0 +1,113 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const errors = [ + Downloads.Error.BLOCK_VERDICT_MALWARE, + Downloads.Error.BLOCK_VERDICT_POTENTIALLY_UNWANTED, + Downloads.Error.BLOCK_VERDICT_INSECURE, + Downloads.Error.BLOCK_VERDICT_UNCOMMON, +]; + +add_task(async function test_confirm_block_download() { + for (let error of errors) { + info(`Testing block ${error} download`); + let histogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD" + ); + + let download; + try { + info(`Create ${error} download`); + if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) { + download = await promiseStartLegacyDownload(null, { + downloadClassification: Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE, + }); + } else { + download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + useLegacySaver: false, + verdict: error, + }); + } + await download.start(); + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + } + + // Test blocked download is recorded + TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 0, 1); + + // Test confirm block + histogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD" + ); + info(`Block ${error} download`); + await download.confirmBlock(); + TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 1, 1); + } +}); + +add_task(async function test_confirm_unblock_download() { + for (let error of errors) { + info(`Testing unblock ${error} download`); + let histogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD" + ); + + let download; + try { + info(`Create ${error} download`); + if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) { + download = await promiseStartLegacyDownload(null, { + downloadClassification: Ci.nsITransfer.DOWNLOAD_POTENTIALLY_UNSAFE, + }); + } else { + download = await promiseBlockedDownload({ + keepPartialData: true, + keepBlockedData: true, + useLegacySaver: false, + verdict: error, + }); + } + await download.start(); + do_throw("The download should have failed."); + } catch (ex) { + if (!(ex instanceof Downloads.Error)) { + throw ex; + } + } + + // Test blocked download is recorded + TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 0, 1); + + // Test unblock + histogram = TelemetryTestUtils.getAndClearKeyedHistogram( + "DOWNLOADS_USER_ACTION_ON_BLOCKED_DOWNLOAD" + ); + info(`Unblock ${error} download`); + let promise = new Promise(r => (download.onchange = r)); + await download.unblock(); + // The environment is not set up properly for performing a real download, cancel + // the unblocked download so it doesn't affect the next testcase. + await download.cancel(); + await promise; + if (error == Downloads.Error.BLOCK_VERDICT_INSECURE) { + Assert.ok(!download.error, "Ensure we didn't set download.error"); + } + + TelemetryTestUtils.assertKeyedHistogramValue(histogram, error, 2, 1); + } +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadCore.js b/toolkit/components/downloads/test/unit/test_DownloadCore.js new file mode 100644 index 0000000000..41aeaac33b --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadCore.js @@ -0,0 +1,291 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the main download interfaces using DownloadCopySaver. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadError: "resource://gre/modules/DownloadCore.sys.mjs", +}); + +// Execution of common tests + +// This is used in common_test_Download.js +// eslint-disable-next-line no-unused-vars +var gUseLegacySaver = false; + +var scriptFile = do_get_file("common_test_Download.js"); +Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec); + +// Tests + +/** + * The download should fail early if the source and the target are the same. + */ +add_task(async function test_error_target_downloadingToSameFile() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + targetFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + let download = await Downloads.createDownload({ + source: NetUtil.newURI(targetFile), + target: targetFile, + }); + await Assert.rejects( + download.start(), + ex => ex instanceof Downloads.Error && ex.becauseTargetFailed + ); + + Assert.ok( + await IOUtils.exists(download.target.path), + "The file should not have been deleted." + ); +}); + +/** + * Tests allowHttpStatus allowing requests + */ +add_task(async function test_error_notfound() { + const targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let called = false; + const download = await Downloads.createDownload({ + source: { + url: httpUrl("notfound.gone"), + allowHttpStatus(aDownload, aStatusCode) { + Assert.strictEqual(download, aDownload, "Check Download objects"); + Assert.strictEqual(aStatusCode, 404, "The status should be correct"); + called = true; + return true; + }, + }, + target: targetFile, + }); + await download.start(); + Assert.ok(called, "allowHttpStatus should have been called"); +}); + +/** + * Tests allowHttpStatus rejecting requests + */ +add_task(async function test_error_notfound_reject() { + const targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let called = false; + const download = await Downloads.createDownload({ + source: { + url: httpUrl("notfound.gone"), + allowHttpStatus(aDownload, aStatusCode) { + Assert.strictEqual(download, aDownload, "Check Download objects"); + Assert.strictEqual(aStatusCode, 404, "The status should be correct"); + called = true; + return false; + }, + }, + target: targetFile, + }); + await Assert.rejects( + download.start(), + ex => ex instanceof Downloads.Error && ex.becauseSourceFailed, + "Download should have been rejected" + ); + Assert.ok(called, "allowHttpStatus should have been called"); +}); + +/** + * Tests allowHttpStatus rejecting requests other than 404 + */ +add_task(async function test_error_busy_reject() { + const targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let called = false; + const download = await Downloads.createDownload({ + source: { + url: httpUrl("busy.txt"), + allowHttpStatus(aDownload, aStatusCode) { + Assert.strictEqual(download, aDownload, "Check Download objects"); + Assert.strictEqual(aStatusCode, 504, "The status should be correct"); + called = true; + return false; + }, + }, + target: targetFile, + }); + await Assert.rejects( + download.start(), + ex => ex instanceof Downloads.Error && ex.becauseSourceFailed, + "Download should have been rejected" + ); + Assert.ok(called, "allowHttpStatus should have been called"); +}); + +/** + * Tests redirects are followed correctly, and the meta data corresponds + * to the correct, final response + */ +add_task(async function test_redirects() { + const targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let called = false; + const download = await Downloads.createDownload({ + source: { + url: httpUrl("redirect"), + allowHttpStatus(aDownload, aStatusCode) { + Assert.strictEqual(download, aDownload, "Check Download objects"); + Assert.strictEqual( + aStatusCode, + 504, + "The status should be correct after a redirect" + ); + called = true; + return true; + }, + }, + target: targetFile, + }); + await download.start(); + Assert.equal( + download.contentType, + "text/plain", + "Content-Type is correct after redirect" + ); + Assert.equal( + download.totalBytes, + TEST_DATA_SHORT.length, + "Content-Length is correct after redirect" + ); + Assert.equal(download.target.size, TEST_DATA_SHORT.length); + Assert.ok(called, "allowHttpStatus should have been called"); +}); + +/** + * Tests the DownloadError object. + */ +add_task(function test_DownloadError() { + let error = new DownloadError({ + result: Cr.NS_ERROR_NOT_RESUMABLE, + message: "Not resumable.", + }); + Assert.equal(error.result, Cr.NS_ERROR_NOT_RESUMABLE); + Assert.equal(error.message, "Not resumable."); + Assert.ok(!error.becauseSourceFailed); + Assert.ok(!error.becauseTargetFailed); + Assert.ok(!error.becauseBlocked); + Assert.ok(!error.becauseBlockedByParentalControls); + + error = new DownloadError({ message: "Unknown error." }); + Assert.equal(error.result, Cr.NS_ERROR_FAILURE); + Assert.equal(error.message, "Unknown error."); + + error = new DownloadError({ result: Cr.NS_ERROR_NOT_RESUMABLE }); + Assert.equal(error.result, Cr.NS_ERROR_NOT_RESUMABLE); + Assert.ok(error.message.indexOf("Exception") > 0); + + // becauseSourceFailed will be set, but not the unknown property. + error = new DownloadError({ + message: "Unknown error.", + becauseSourceFailed: true, + becauseUnknown: true, + }); + Assert.ok(error.becauseSourceFailed); + Assert.equal(false, "becauseUnknown" in error); + + error = new DownloadError({ + result: Cr.NS_ERROR_MALFORMED_URI, + inferCause: true, + }); + Assert.equal(error.result, Cr.NS_ERROR_MALFORMED_URI); + Assert.ok(error.becauseSourceFailed); + Assert.ok(!error.becauseTargetFailed); + Assert.ok(!error.becauseBlocked); + Assert.ok(!error.becauseBlockedByParentalControls); + + // This test does not set inferCause, so becauseSourceFailed will not be set. + error = new DownloadError({ result: Cr.NS_ERROR_MALFORMED_URI }); + Assert.equal(error.result, Cr.NS_ERROR_MALFORMED_URI); + Assert.ok(!error.becauseSourceFailed); + + error = new DownloadError({ + result: Cr.NS_ERROR_FILE_INVALID_PATH, + inferCause: true, + }); + Assert.equal(error.result, Cr.NS_ERROR_FILE_INVALID_PATH); + Assert.ok(!error.becauseSourceFailed); + Assert.ok(error.becauseTargetFailed); + Assert.ok(!error.becauseBlocked); + Assert.ok(!error.becauseBlockedByParentalControls); + + error = new DownloadError({ becauseBlocked: true }); + Assert.equal(error.message, "Download blocked."); + Assert.ok(!error.becauseSourceFailed); + Assert.ok(!error.becauseTargetFailed); + Assert.ok(error.becauseBlocked); + Assert.ok(!error.becauseBlockedByParentalControls); + + error = new DownloadError({ becauseBlockedByParentalControls: true }); + Assert.equal(error.message, "Download blocked."); + Assert.ok(!error.becauseSourceFailed); + Assert.ok(!error.becauseTargetFailed); + Assert.ok(error.becauseBlocked); + Assert.ok(error.becauseBlockedByParentalControls); +}); + +add_task(async function test_cancel_interrupted_download() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + + let download = await Downloads.createDownload({ + source: httpUrl("interruptible_resumable.txt"), + target: targetFile, + }); + + async function createAndCancelDownload() { + info("Create an interruptible download and cancel it midway"); + mustInterruptResponses(); + const promiseDownloaded = download.start(); + await promiseDownloadMidway(download); + await download.cancel(); + + info("Unblock the interruptible download and wait for its annotation"); + continueResponses(); + await waitForAnnotation( + httpUrl("interruptible_resumable.txt"), + "downloads/destinationFileURI" + ); + + await Assert.rejects( + promiseDownloaded, + /DownloadError: Download canceled/, + "Got a download error as expected" + ); + } + + await new Promise(resolve => { + const DONE = "=== download xpcshell test console listener done ==="; + const logDone = () => Services.console.logStringMessage(DONE); + const consoleListener = msg => { + if (msg == DONE) { + Services.console.unregisterListener(consoleListener); + resolve(); + } + }; + Services.console.reset(); + Services.console.registerListener(consoleListener); + + createAndCancelDownload().then(logDone); + }); + + info( + "Assert that nsIStreamListener.onDataAvailable has not been called after download.cancel" + ); + let found = Services.console + .getMessageArray() + .map(m => m.message) + .filter(message => { + return message.includes("nsIStreamListener.onDataAvailable"); + }); + Assert.deepEqual( + found, + [], + "Expect no nsIStreamListener.onDataAvaialable error" + ); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory.js b/toolkit/components/downloads/test/unit/test_DownloadHistory.js new file mode 100644 index 0000000000..69eb1c4728 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadHistory.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadHistory module. + */ + +"use strict"; + +const { DownloadHistory } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadHistory.sys.mjs" +); + +let baseDate = new Date("2000-01-01"); + +/** + * Non-fatal assertion used to test whether the downloads in the list already + * match the expected state. + */ +function areEqual(a, b) { + if (a === b) { + Assert.equal(a, b); + return true; + } + info(a + " !== " + b); + return false; +} + +/** + * This allows waiting for an expected list at various points during the test. + */ +class TestView { + constructor(expected) { + this.expected = [...expected]; + this.downloads = []; + this.resolveWhenExpected = () => {}; + } + onDownloadAdded(download, options = {}) { + if (options.insertBefore) { + let index = this.downloads.indexOf(options.insertBefore); + this.downloads.splice(index, 0, download); + } else { + this.downloads.push(download); + } + this.checkForExpectedDownloads(); + } + onDownloadChanged(download) { + this.checkForExpectedDownloads(); + } + onDownloadRemoved(download) { + let index = this.downloads.indexOf(download); + this.downloads.splice(index, 1); + this.checkForExpectedDownloads(); + } + checkForExpectedDownloads() { + // Wait for all the expected downloads to be added or removed before doing + // the detailed tests. This is done to avoid creating irrelevant output. + if (this.downloads.length != this.expected.length) { + return; + } + for (let i = 0; i < this.downloads.length; i++) { + if ( + this.downloads[i].source.url != this.expected[i].source.url || + this.downloads[i].target.path != this.expected[i].target.path + ) { + return; + } + } + // Check and report the actual state of the downloads. Even if the items + // are in the expected order, the metadata for history downloads might not + // have been updated to the final state yet. + for (let i = 0; i < this.downloads.length; i++) { + let download = this.downloads[i]; + let testDownload = this.expected[i]; + info( + "Checking download source " + + download.source.url + + " with target " + + download.target.path + ); + if ( + !areEqual(download.succeeded, !!testDownload.succeeded) || + !areEqual(download.canceled, !!testDownload.canceled) || + !areEqual(download.hasPartialData, !!testDownload.hasPartialData) || + !areEqual(!!download.error, !!testDownload.error) + ) { + return; + } + // If the above properties match, the error details should be correct. + if (download.error) { + if (testDownload.error.becauseSourceFailed) { + Assert.equal(download.error.message, "History download failed."); + } + Assert.equal( + download.error.becauseBlockedByParentalControls, + testDownload.error.becauseBlockedByParentalControls + ); + Assert.equal( + download.error.becauseBlockedByReputationCheck, + testDownload.error.becauseBlockedByReputationCheck + ); + } + } + this.resolveWhenExpected(); + } + async waitForExpected() { + let promise = new Promise(resolve => (this.resolveWhenExpected = resolve)); + this.checkForExpectedDownloads(); + await promise; + } +} + +/** + * Tests that various operations on session and history downloads are reflected + * by the DownloadHistoryList object, and that the order of results is correct. + */ +add_task(async function test_DownloadHistory() { + // Clean up at the beginning and at the end of the test. + async function cleanup() { + await PlacesUtils.history.clear(); + } + registerCleanupFunction(cleanup); + await cleanup(); + + let testDownloads = [ + // History downloads should appear in order at the beginning of the list. + { offset: 10, canceled: true }, + { offset: 20, succeeded: true }, + { offset: 30, error: { becauseSourceFailed: true } }, + { offset: 40, error: { becauseBlockedByParentalControls: true } }, + { offset: 50, error: { becauseBlockedByReputationCheck: true } }, + // Session downloads should show up after all the history download, in the + // same order as they were added. + { offset: 45, canceled: true, inSession: true }, + { offset: 35, canceled: true, hasPartialData: true, inSession: true }, + { offset: 55, succeeded: true, inSession: true }, + ]; + const NEXT_OFFSET = 60; + + let publicList = await promiseNewList(); + let allList = await Downloads.getList(Downloads.ALL); + + async function addTestDownload(properties) { + properties.source = { + url: httpUrl("source" + properties.offset), + isPrivate: properties.isPrivate, + }; + let targetFile = getTempFile(TEST_TARGET_FILE_NAME + properties.offset); + properties.target = { path: targetFile.path }; + properties.startTime = new Date(baseDate.getTime() + properties.offset); + + let download = await Downloads.createDownload(properties); + if (properties.inSession) { + await allList.add(download); + } + + if (properties.isPrivate) { + return; + } + + // Add the download to history using the XPCOM service, then use the + // DownloadHistory module to save the associated metadata. + let promiseFileAnnotation = waitForAnnotation( + properties.source.url, + "downloads/destinationFileURI" + ); + let promiseMetaAnnotation = waitForAnnotation( + properties.source.url, + "downloads/metaData" + ); + let promiseVisit = promiseWaitForVisit(properties.source.url); + await DownloadHistory.addDownloadToHistory(download); + await promiseVisit; + await DownloadHistory.updateMetaData(download); + await Promise.all([promiseFileAnnotation, promiseMetaAnnotation]); + } + + // Add all the test downloads to history. + for (let properties of testDownloads) { + await addTestDownload(properties); + } + + // Initialize DownloadHistoryList only after having added the history and + // session downloads, and check that they are loaded in the correct order. + let historyList = await DownloadHistory.getList(); + let view = new TestView(testDownloads); + await historyList.addView(view); + await view.waitForExpected(); + + // Remove a download from history and verify that the change is reflected. + let downloadToRemove = view.expected[1]; + view.expected.splice(1, 1); + await PlacesUtils.history.remove(downloadToRemove.source.url); + await view.waitForExpected(); + + // Add a download to history and verify it's placed before session downloads, + // even if the start date is more recent. + let downloadToAdd = { offset: NEXT_OFFSET, canceled: true }; + view.expected.splice( + view.expected.findIndex(d => d.inSession), + 0, + downloadToAdd + ); + await addTestDownload(downloadToAdd); + await view.waitForExpected(); + + // Add a session download and verify it's placed after all session downloads, + // even if the start date is less recent. + let sessionDownloadToAdd = { offset: 0, inSession: true, succeeded: true }; + view.expected.push(sessionDownloadToAdd); + await addTestDownload(sessionDownloadToAdd); + await view.waitForExpected(); + + // Add a session download for the same URI without a history entry, and verify + // it's visible and placed after all session downloads. + view.expected.push(sessionDownloadToAdd); + await publicList.add(await Downloads.createDownload(sessionDownloadToAdd)); + await view.waitForExpected(); + + // Create a new DownloadHistoryList that also shows private downloads. Since + // we only have public downloads, the two lists should contain the same items. + let allHistoryList = await DownloadHistory.getList({ type: Downloads.ALL }); + let allView = new TestView(view.expected); + await allHistoryList.addView(allView); + await allView.waitForExpected(); + + // Add a new private download and verify it appears only on the complete list. + let privateDownloadToAdd = { + offset: NEXT_OFFSET + 10, + inSession: true, + succeeded: true, + isPrivate: true, + }; + allView.expected.push(privateDownloadToAdd); + await addTestDownload(privateDownloadToAdd); + await view.waitForExpected(); + await allView.waitForExpected(); + + // Now test the maxHistoryResults parameter. + let allHistoryList2 = await DownloadHistory.getList({ + type: Downloads.ALL, + maxHistoryResults: 3, + }); + // Prepare the set of downloads to contain fewer history downloads by removing + // the oldest ones. + let allView2 = new TestView(allView.expected.slice(3)); + await allHistoryList2.addView(allView2); + await allView2.waitForExpected(); + + // Create a dummy list and view like the previous limited one to just add and + // remove its view to make sure it doesn't break other lists' updates. + let dummyList = await DownloadHistory.getList({ + type: Downloads.ALL, + maxHistoryResults: 3, + }); + let dummyView = new TestView([]); + await dummyList.addView(dummyView); + await dummyList.removeView(dummyView); + + // Clear history and check that session downloads with partial data remain. + // Private downloads are also not cleared when clearing history. + view.expected = view.expected.filter(d => d.hasPartialData); + allView.expected = allView.expected.filter( + d => d.hasPartialData || d.isPrivate + ); + await PlacesUtils.history.clear(); + await view.waitForExpected(); + await allView.waitForExpected(); + + // Check that the dummy view above did not prevent the limited from updating. + allView2.expected = allView.expected; + await allView2.waitForExpected(); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js new file mode 100644 index 0000000000..ea41038bf1 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadHistory } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadHistory.sys.mjs" +); +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +let baseDate = new Date("2000-01-01"); + +/** + * This test is designed to ensure the cache of download history is correctly + * loaded and initialized. We do this by having the test as the only test in + * this file, and injecting data into the places database before we start. + */ +add_task(async function test_DownloadHistory_initialization() { + // Clean up at the beginning and at the end of the test. + async function cleanup() { + await PlacesUtils.history.clear(); + } + registerCleanupFunction(cleanup); + await cleanup(); + + let testDownloads = []; + for (let i = 10; i <= 30; i += 10) { + let targetFile = getTempFile(`${TEST_TARGET_FILE_NAME}${i}`); + let download = { + source: { + url: httpUrl(`source${i}`), + isPrivate: false, + }, + target: { path: targetFile.path }, + endTime: baseDate.getTime() + i, + fileSize: 100 + i, + state: i / 10, + }; + + await PlacesTestUtils.addVisits([ + { + uri: download.source.url, + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + }, + ]); + + let targetUri = Services.io.newFileURI( + new FileUtils.File(download.target.path) + ); + + await PlacesUtils.history.update({ + annotations: new Map([ + ["downloads/destinationFileURI", targetUri.spec], + [ + "downloads/metaData", + JSON.stringify({ + state: download.state, + endTime: download.endTime, + fileSize: download.fileSize, + }), + ], + ]), + url: download.source.url, + }); + + testDownloads.push(download); + } + + // Initialize DownloadHistoryList only after having added the history and + // session downloads. + let historyList = await DownloadHistory.getList(); + let downloads = await historyList.getAll(); + Assert.equal(downloads.length, testDownloads.length); + + for (let expected of testDownloads) { + let download = downloads.find(d => d.source.url == expected.source.url); + + info(`Checking download ${expected.source.url}`); + Assert.ok(download, "Should have found the expected download"); + Assert.equal( + download.endTime, + expected.endTime, + "Should have the correct end time" + ); + Assert.equal( + download.target.size, + expected.fileSize, + "Should have the correct file size" + ); + Assert.equal( + download.succeeded, + expected.state == 1, + "Should have the correct succeeded value" + ); + Assert.equal( + download.canceled, + expected.state == 3, + "Should have the correct canceled value" + ); + Assert.equal( + download.target.path, + expected.target.path, + "Should have the correct target path" + ); + } +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js new file mode 100644 index 0000000000..8b714a8fae --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadHistory_initialization2.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { DownloadHistory } = ChromeUtils.importESModule( + "resource://gre/modules/DownloadHistory.sys.mjs" +); + +/** + * This test is designed to ensure the cache of download history is correctly + * loaded and initialized via adding downloads. We do this by having the test as + * the only test in this file. + */ +add_task(async function test_initialization_via_addDownload() { + // Clean up at the beginning and at the end of the test. + async function cleanup() { + await PlacesUtils.history.clear(); + } + registerCleanupFunction(cleanup); + await cleanup(); + + const download1FileLocation = getTempFile(`${TEST_TARGET_FILE_NAME}1`).path; + const download2FileLocation = getTempFile(`${TEST_TARGET_FILE_NAME}2`).path; + const download = { + source: { + url: httpUrl(`source1`), + isPrivate: false, + }, + target: { path: download1FileLocation }, + }; + + await DownloadHistory.addDownloadToHistory(download); + + // Initialize DownloadHistoryList only after having added the history and + // session downloads. + let historyList = await DownloadHistory.getList(); + let downloads = await historyList.getAll(); + Assert.equal(downloads.length, 1, "Should have only one entry"); + + Assert.equal( + downloads[0].target.path, + download1FileLocation, + "Should have the correct target path" + ); + + // Now re-add the download but with a different target. + download.target.path = download2FileLocation; + + await DownloadHistory.addDownloadToHistory(download); + + historyList = await DownloadHistory.getList(); + downloads = await historyList.getAll(); + Assert.equal(downloads.length, 1, "Should still have only one entry"); + + Assert.equal( + downloads[0].target.path, + download2FileLocation, + "Should have the correct revised target path" + ); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadIntegration.js b/toolkit/components/downloads/test/unit/test_DownloadIntegration.js new file mode 100644 index 0000000000..7ee34e8307 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadIntegration.js @@ -0,0 +1,441 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadIntegration object. + */ + +"use strict"; + +// Globals + +/** + * Notifies the prompt observers and verify the expected downloads count. + * + * @param aIsPrivate + * Flag to know is test private observers. + * @param aExpectedCount + * the expected downloads count for quit and offline observers. + * @param aExpectedPBCount + * the expected downloads count for private browsing observer. + */ +function notifyPromptObservers(aIsPrivate, aExpectedCount, aExpectedPBCount) { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + + // Notify quit application requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedCount); + + // Notify offline requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "offline-requested"); + Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedCount); + + if (aIsPrivate) { + // Notify last private browsing requested observer. + DownloadIntegration._testPromptDownloads = -1; + Services.obs.notifyObservers(cancelQuit, "last-pb-context-exiting"); + Assert.equal(DownloadIntegration._testPromptDownloads, aExpectedPBCount); + } + + delete DownloadIntegration._testPromptDownloads; +} + +// Tests + +/** + * Allows re-enabling the real download directory logic during one test. + */ +function allowDirectoriesInTest() { + DownloadIntegration.allowDirectories = true; + function cleanup() { + DownloadIntegration.allowDirectories = false; + } + registerCleanupFunction(cleanup); + return cleanup; +} + +ChromeUtils.defineLazyGetter(this, "gStringBundle", function () { + return Services.strings.createBundle( + "chrome://mozapps/locale/downloads/downloads.properties" + ); +}); + +/** + * Tests that getSystemDownloadsDirectory returns an existing directory or + * creates a new directory depending on the platform. Instead of the real + * directory, this test is executed in the temporary directory so we can safely + * delete the created folder to check whether it is created again. + */ +add_task(async function test_getSystemDownloadsDirectory_exists_or_creates() { + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + let downloadDir; + + // OSX / Linux / Windows but not XP/2k + if ( + Services.appinfo.OS == "Darwin" || + Services.appinfo.OS == "Linux" || + (Services.appinfo.OS == "WINNT" && + parseFloat(Services.sysinfo.getProperty("version")) >= 6) + ) { + downloadDir = await DownloadIntegration.getSystemDownloadsDirectory(); + Assert.equal(downloadDir, tempDir.path); + Assert.ok(await IOUtils.exists(downloadDir)); + + let info = await IOUtils.stat(downloadDir); + Assert.equal(info.type, "directory"); + } else { + let targetPath = PathUtils.join( + tempDir.path, + gStringBundle.GetStringFromName("downloadsFolder") + ); + try { + await IOUtils.remove(targetPath); + } catch (e) {} + downloadDir = await DownloadIntegration.getSystemDownloadsDirectory(); + Assert.equal(downloadDir, targetPath); + Assert.ok(await IOUtils.exists(downloadDir)); + + let info = await IOUtils.stat(downloadDir); + Assert.equal(info.type, "directory"); + await IOUtils.remove(targetPath); + } +}); + +/** + * Tests that the real directory returned by getSystemDownloadsDirectory is not + * the one that is used during unit tests. Since this is the actual downloads + * directory of the operating system, we don't try to delete it afterwards. + */ +add_task(async function test_getSystemDownloadsDirectory_real() { + let fakeDownloadDir = await DownloadIntegration.getSystemDownloadsDirectory(); + + let cleanup = allowDirectoriesInTest(); + let realDownloadDir = await DownloadIntegration.getSystemDownloadsDirectory(); + cleanup(); + + Assert.notEqual(fakeDownloadDir, realDownloadDir); +}); + +/** + * Tests that the getPreferredDownloadsDirectory returns a valid download + * directory string path. + */ +add_task(async function test_getPreferredDownloadsDirectory() { + let cleanupDirectories = allowDirectoriesInTest(); + + let folderListPrefName = "browser.download.folderList"; + let dirPrefName = "browser.download.dir"; + function cleanupPrefs() { + Services.prefs.clearUserPref(folderListPrefName); + Services.prefs.clearUserPref(dirPrefName); + } + registerCleanupFunction(cleanupPrefs); + + // For legacy cloudstorage users with folderListPrefName as 3, + // Should return the system downloads directory because the dir preference + // is not set. + Services.prefs.setIntPref(folderListPrefName, 3); + let systemDir = await DownloadIntegration.getSystemDownloadsDirectory(); + let downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, systemDir); + + // Should return the system downloads directory. + Services.prefs.setIntPref(folderListPrefName, 1); + systemDir = await DownloadIntegration.getSystemDownloadsDirectory(); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, systemDir); + + // Should return the desktop directory. + Services.prefs.setIntPref(folderListPrefName, 0); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, Services.dirsvc.get("Desk", Ci.nsIFile).path); + + // Should return the system downloads directory because the dir preference + // is not set. + Services.prefs.setIntPref(folderListPrefName, 2); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, systemDir); + + // Should return the directory which is listed in the dir preference. + let time = new Date().getTime(); + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append(time); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + Assert.equal(downloadDir, tempDir.path); + Assert.ok(await IOUtils.exists(downloadDir)); + await IOUtils.remove(tempDir.path); + + // Should return the system downloads directory beacause the path is invalid + // in the dir preference. + tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append("dir_not_exist"); + tempDir.append(time); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsIFile, tempDir); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.equal(downloadDir, systemDir); + + // Should return the system downloads directory because the folderList + // preference is invalid + Services.prefs.setIntPref(folderListPrefName, 999); + downloadDir = await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.equal(downloadDir, systemDir); + + cleanupPrefs(); + cleanupDirectories(); +}); + +/** + * Tests that the getTemporaryDownloadsDirectory returns a valid download + * directory string path. + */ +add_task(async function test_getTemporaryDownloadsDirectory() { + let cleanup = allowDirectoriesInTest(); + + let downloadDir = await DownloadIntegration.getTemporaryDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); + + if ("nsILocalFileMac" in Ci) { + let preferredDownloadDir = + await DownloadIntegration.getPreferredDownloadsDirectory(); + Assert.equal(downloadDir, preferredDownloadDir); + } else { + let tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + Assert.equal(downloadDir, tempDir.path); + } + + cleanup(); +}); + +// Tests DownloadObserver + +/** + * Re-enables the default observers for the following tests. + * + * This takes effect the first time a DownloadList object is created, and lasts + * until this test file has completed. + */ +add_task(async function test_observers_setup() { + DownloadIntegration.allowObservers = true; + registerCleanupFunction(function () { + DownloadIntegration.allowObservers = false; + }); +}); + +/** + * Tests notifications prompts when observers are notified if there are public + * and private active downloads. + */ +add_task(async function test_notifications() { + for (let isPrivate of [false, true]) { + mustInterruptResponses(); + + let list = await promiseNewList(isPrivate); + let download1 = await promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = await promiseNewDownload(httpUrl("interruptible.txt")); + let download3 = await promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + let promiseAttempt2 = download2.start(); + download3.start().catch(() => {}); + + // Add downloads to list. + await list.add(download1); + await list.add(download2); + await list.add(download3); + // Cancel third download + await download3.cancel(); + + notifyPromptObservers(isPrivate, 2, 2); + + // Allow the downloads to complete. + continueResponses(); + await promiseAttempt1; + await promiseAttempt2; + + // Clean up. + await list.remove(download1); + await list.remove(download2); + await list.remove(download3); + } +}); + +/** + * Tests that notifications prompts observers are not notified if there are no + * public or private active downloads. + */ +add_task(async function test_no_notifications() { + for (let isPrivate of [false, true]) { + let list = await promiseNewList(isPrivate); + let download1 = await promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = await promiseNewDownload(httpUrl("interruptible.txt")); + download1.start().catch(() => {}); + download2.start().catch(() => {}); + + // Add downloads to list. + await list.add(download1); + await list.add(download2); + + await download1.cancel(); + await download2.cancel(); + + notifyPromptObservers(isPrivate, 0, 0); + + // Clean up. + await list.remove(download1); + await list.remove(download2); + } +}); + +/** + * Tests notifications prompts when observers are notified if there are public + * and private active downloads at the same time. + */ +add_task(async function test_mix_notifications() { + mustInterruptResponses(); + + let publicList = await promiseNewList(); + let privateList = await Downloads.getList(Downloads.PRIVATE); + let download1 = await promiseNewDownload(httpUrl("interruptible.txt")); + let download2 = await promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + let promiseAttempt2 = download2.start(); + + // Add downloads to lists. + await publicList.add(download1); + await privateList.add(download2); + + notifyPromptObservers(true, 2, 1); + + // Allow the downloads to complete. + continueResponses(); + await promiseAttempt1; + await promiseAttempt2; + + // Clean up. + await publicList.remove(download1); + await privateList.remove(download2); +}); + +/** + * Tests suspending and resuming as well as going offline and then online again. + * The downloads should stop when suspending and start again when resuming. + */ +add_task(async function test_suspend_resume() { + // The default wake delay is 10 seconds, so set the wake delay to be much + // faster for these tests. + Services.prefs.setIntPref("browser.download.manager.resumeOnWakeDelay", 5); + + let addDownload = function (list) { + return (async function () { + let download = await promiseNewDownload(httpUrl("interruptible.txt")); + download.start().catch(() => {}); + list.add(download); + return download; + })(); + }; + + let publicList = await promiseNewList(); + let privateList = await promiseNewList(true); + + let download1 = await addDownload(publicList); + let download2 = await addDownload(publicList); + let download3 = await addDownload(privateList); + let download4 = await addDownload(privateList); + let download5 = await addDownload(publicList); + + // First, check that the downloads are all canceled when going to sleep. + Services.obs.notifyObservers(null, "sleep_notification"); + Assert.ok(download1.canceled); + Assert.ok(download2.canceled); + Assert.ok(download3.canceled); + Assert.ok(download4.canceled); + Assert.ok(download5.canceled); + + // Remove a download. It should not be started again. + publicList.remove(download5); + Assert.ok(download5.canceled); + + // When waking up again, the downloads start again after the wake delay. To be + // more robust, don't check after a delay but instead just wait for the + // downloads to finish. + Services.obs.notifyObservers(null, "wake_notification"); + await download1.whenSucceeded(); + await download2.whenSucceeded(); + await download3.whenSucceeded(); + await download4.whenSucceeded(); + + // Downloads should no longer be canceled. However, as download5 was removed + // from the public list, it will not be restarted. + Assert.ok(!download1.canceled); + Assert.ok(download5.canceled); + + // Create four new downloads and check for going offline and then online again. + + download1 = await addDownload(publicList); + download2 = await addDownload(publicList); + download3 = await addDownload(privateList); + download4 = await addDownload(privateList); + + // Going offline should cancel the downloads. + Services.obs.notifyObservers(null, "network:offline-about-to-go-offline"); + Assert.ok(download1.canceled); + Assert.ok(download2.canceled); + Assert.ok(download3.canceled); + Assert.ok(download4.canceled); + + // Going back online should start the downloads again. + Services.obs.notifyObservers( + null, + "network:offline-status-changed", + "online" + ); + await download1.whenSucceeded(); + await download2.whenSucceeded(); + await download3.whenSucceeded(); + await download4.whenSucceeded(); + + Services.prefs.clearUserPref("browser.download.manager.resumeOnWakeDelay"); +}); + +/** + * Tests both the downloads list and the in-progress downloads are clear when + * private browsing observer is notified. + */ +add_task(async function test_exit_private_browsing() { + mustInterruptResponses(); + + let privateList = await promiseNewList(true); + let download1 = await promiseNewDownload(httpUrl("source.txt")); + let download2 = await promiseNewDownload(httpUrl("interruptible.txt")); + let promiseAttempt1 = download1.start(); + download2.start(); + + // Add downloads to list. + await privateList.add(download1); + await privateList.add(download2); + + // Complete the download. + await promiseAttempt1; + + Assert.equal((await privateList.getAll()).length, 2); + + // Simulate exiting the private browsing. + await new Promise(resolve => { + DownloadIntegration._testResolveClearPrivateList = resolve; + Services.obs.notifyObservers(null, "last-pb-context-exited"); + }); + delete DownloadIntegration._testResolveClearPrivateList; + + Assert.equal((await privateList.getAll()).length, 0); + + continueResponses(); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadLegacy.js b/toolkit/components/downloads/test/unit/test_DownloadLegacy.js new file mode 100644 index 0000000000..972820f29e --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadLegacy.js @@ -0,0 +1,100 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the integration with legacy interfaces for downloads. + */ + +"use strict"; + +// Execution of common tests + +// This is used in common_test_Download.js +// eslint-disable-next-line no-unused-vars +var gUseLegacySaver = true; + +var scriptFile = do_get_file("common_test_Download.js"); +Services.scriptloader.loadSubScript(NetUtil.newURI(scriptFile).spec); + +/** + * Checks the referrer for restart downloads. + * If the legacy download is stopped and restarted, the saving method + * is changed from DownloadLegacySaver to the DownloadCopySaver. + * The referrer header should be passed correctly. + */ +add_task(async function test_referrer_restart() { + let sourcePath = "/test_referrer_restart.txt"; + let sourceUrl = httpUrl("test_referrer_restart.txt"); + + function cleanup() { + gHttpServer.registerPathHandler(sourcePath, null); + } + registerCleanupFunction(cleanup); + + registerInterruptibleHandler( + sourcePath, + function firstPart(aRequest, aResponse) { + aResponse.setHeader("Content-Type", "text/plain", false); + Assert.ok(aRequest.hasHeader("Referer")); + Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL); + + aResponse.setHeader( + "Content-Length", + "" + TEST_DATA_SHORT.length * 2, + false + ); + aResponse.write(TEST_DATA_SHORT); + }, + function secondPart(aRequest, aResponse) { + Assert.ok(aRequest.hasHeader("Referer")); + Assert.equal(aRequest.getHeader("Referer"), TEST_REFERRER_URL); + + aResponse.write(TEST_DATA_SHORT); + } + ); + + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.UNSAFE_URL, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + + async function restart_and_check_referrer(download) { + let promiseSucceeded = download.whenSucceeded(); + + // Cancel the first download attempt. + await promiseDownloadMidway(download); + await download.cancel(); + + // The second request is allowed to complete. + continueResponses(); + download.start().catch(() => {}); + + // Wait for the download to finish by waiting on the whenSucceeded promise. + await promiseSucceeded; + + Assert.ok(download.stopped); + Assert.ok(download.succeeded); + Assert.ok(!download.canceled); + + checkEqualReferrerInfos(download.source.referrerInfo, referrerInfo); + } + + mustInterruptResponses(); + + let download = await promiseStartLegacyDownload(sourceUrl, { + referrerInfo, + }); + await restart_and_check_referrer(download); + + mustInterruptResponses(); + download = await promiseStartLegacyDownload(sourceUrl, { + referrerInfo, + isPrivate: true, + }); + await restart_and_check_referrer(download); + + cleanup(); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadList.js b/toolkit/components/downloads/test/unit/test_DownloadList.js new file mode 100644 index 0000000000..f7d03900af --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadList.js @@ -0,0 +1,677 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadList object. + */ + +"use strict"; + +// Globals + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +/** + * Returns a Date in the past usable to add expirable visits. + * + * @note Expiration ignores any visit added in the last 7 days, but it's + * better be safe against DST issues, by going back one day more. + */ +function getExpirableDate() { + let dateObj = new Date(); + // Normalize to midnight + dateObj.setHours(0); + dateObj.setMinutes(0); + dateObj.setSeconds(0); + dateObj.setMilliseconds(0); + return new Date(dateObj.getTime() - 8 * 86400000); +} + +/** + * Adds an expirable history visit for a download. + * + * @param aSourceUrl + * String containing the URI for the download source, or null to use + * httpUrl("source.txt"). + * + * @return {Promise} + * @rejects JavaScript exception. + */ +function promiseExpirableDownloadVisit(aSourceUrl) { + return PlacesUtils.history.insert({ + url: aSourceUrl || httpUrl("source.txt"), + visits: [ + { + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + date: getExpirableDate(), + }, + ], + }); +} + +// Tests + +/** + * Checks the testing mechanism used to build different download lists. + */ +add_task(async function test_construction() { + let downloadListOne = await promiseNewList(); + let downloadListTwo = await promiseNewList(); + let privateDownloadListOne = await promiseNewList(true); + let privateDownloadListTwo = await promiseNewList(true); + + Assert.notEqual(downloadListOne, downloadListTwo); + Assert.notEqual(privateDownloadListOne, privateDownloadListTwo); + Assert.notEqual(downloadListOne, privateDownloadListOne); +}); + +/** + * Checks the methods to add and retrieve items from the list. + */ +add_task(async function test_add_getAll() { + let list = await promiseNewList(); + + let downloadOne = await promiseNewDownload(); + await list.add(downloadOne); + + let itemsOne = await list.getAll(); + Assert.equal(itemsOne.length, 1); + Assert.equal(itemsOne[0], downloadOne); + + let downloadTwo = await promiseNewDownload(); + await list.add(downloadTwo); + + let itemsTwo = await list.getAll(); + Assert.equal(itemsTwo.length, 2); + Assert.equal(itemsTwo[0], downloadOne); + Assert.equal(itemsTwo[1], downloadTwo); + + // The first snapshot should not have been modified. + Assert.equal(itemsOne.length, 1); +}); + +/** + * Checks the method to remove items from the list. + */ +add_task(async function test_remove() { + let list = await promiseNewList(); + + await list.add(await promiseNewDownload()); + await list.add(await promiseNewDownload()); + + let items = await list.getAll(); + await list.remove(items[0]); + + // Removing an item that was never added should not raise an error. + await list.remove(await promiseNewDownload()); + + items = await list.getAll(); + Assert.equal(items.length, 1); +}); + +/** + * Tests that the "add", "remove", and "getAll" methods on the global + * DownloadCombinedList object combine the contents of the global DownloadList + * objects for public and private downloads. + */ +add_task(async function test_DownloadCombinedList_add_remove_getAll() { + let publicList = await promiseNewList(); + let privateList = await Downloads.getList(Downloads.PRIVATE); + let combinedList = await Downloads.getList(Downloads.ALL); + + let publicDownload = await promiseNewDownload(); + let privateDownload = await Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + + await publicList.add(publicDownload); + await privateList.add(privateDownload); + + Assert.equal((await combinedList.getAll()).length, 2); + + await combinedList.remove(publicDownload); + await combinedList.remove(privateDownload); + + Assert.equal((await combinedList.getAll()).length, 0); + + await combinedList.add(publicDownload); + await combinedList.add(privateDownload); + + Assert.equal((await publicList.getAll()).length, 1); + Assert.equal((await privateList.getAll()).length, 1); + Assert.equal((await combinedList.getAll()).length, 2); + + await publicList.remove(publicDownload); + await privateList.remove(privateDownload); + + Assert.equal((await combinedList.getAll()).length, 0); +}); + +/** + * Checks that views receive the download add and remove notifications, and that + * adding and removing views works as expected, both for a normal and a combined + * list. + */ +add_task(async function test_notifications_add_remove() { + for (let isCombined of [false, true]) { + // Force creating a new list for both the public and combined cases. + let list = await promiseNewList(); + if (isCombined) { + list = await Downloads.getList(Downloads.ALL); + } + + let downloadOne = await promiseNewDownload(); + let downloadTwo = await Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + await list.add(downloadOne); + await list.add(downloadTwo); + + // Check that we receive add notifications for existing elements. + let addNotifications = 0; + let viewOne = { + onDownloadAdded(aDownload) { + // The first download to be notified should be the first that was added. + if (addNotifications == 0) { + Assert.equal(aDownload, downloadOne); + } else if (addNotifications == 1) { + Assert.equal(aDownload, downloadTwo); + } + addNotifications++; + }, + }; + await list.addView(viewOne); + Assert.equal(addNotifications, 2); + + // Check that we receive add notifications for new elements. + await list.add(await promiseNewDownload()); + Assert.equal(addNotifications, 3); + + // Check that we receive remove notifications. + let removeNotifications = 0; + let viewTwo = { + onDownloadRemoved(aDownload) { + Assert.equal(aDownload, downloadOne); + removeNotifications++; + }, + }; + await list.addView(viewTwo); + await list.remove(downloadOne); + Assert.equal(removeNotifications, 1); + + // We should not receive remove notifications after the view is removed. + await list.removeView(viewTwo); + await list.remove(downloadTwo); + Assert.equal(removeNotifications, 1); + + // We should not receive add notifications after the view is removed. + await list.removeView(viewOne); + await list.add(await promiseNewDownload()); + Assert.equal(addNotifications, 3); + } +}); + +/** + * Checks that views receive the download change notifications, both for a + * normal and a combined list. + */ +add_task(async function test_notifications_change() { + for (let isCombined of [false, true]) { + // Force creating a new list for both the public and combined cases. + let list = await promiseNewList(); + if (isCombined) { + list = await Downloads.getList(Downloads.ALL); + } + + let downloadOne = await promiseNewDownload(); + let downloadTwo = await Downloads.createDownload({ + source: { url: httpUrl("source.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + await list.add(downloadOne); + await list.add(downloadTwo); + + // Check that we receive change notifications. + let receivedOnDownloadChanged = false; + await list.addView({ + onDownloadChanged(aDownload) { + Assert.equal(aDownload, downloadOne); + receivedOnDownloadChanged = true; + }, + }); + await downloadOne.start(); + Assert.ok(receivedOnDownloadChanged); + + // We should not receive change notifications after a download is removed. + receivedOnDownloadChanged = false; + await list.remove(downloadTwo); + await downloadTwo.start(); + Assert.ok(!receivedOnDownloadChanged); + } +}); + +/** + * Checks that the reference to "this" is correct in the view callbacks. + */ +add_task(async function test_notifications_this() { + let list = await promiseNewList(); + + // Check that we receive change notifications. + let receivedOnDownloadAdded = false; + let receivedOnDownloadChanged = false; + let receivedOnDownloadRemoved = false; + let view = { + onDownloadAdded() { + Assert.equal(this, view); + receivedOnDownloadAdded = true; + }, + onDownloadChanged() { + // Only do this check once. + if (!receivedOnDownloadChanged) { + Assert.equal(this, view); + receivedOnDownloadChanged = true; + } + }, + onDownloadRemoved() { + Assert.equal(this, view); + receivedOnDownloadRemoved = true; + }, + }; + await list.addView(view); + + let download = await promiseNewDownload(); + await list.add(download); + await download.start(); + await list.remove(download); + + // Verify that we executed the checks. + Assert.ok(receivedOnDownloadAdded); + Assert.ok(receivedOnDownloadChanged); + Assert.ok(receivedOnDownloadRemoved); +}); + +/** + * Checks that download is removed on history expiration. + */ +add_task(async function test_history_expiration() { + mustInterruptResponses(); + + function cleanup() { + Services.prefs.clearUserPref("places.history.expiration.max_pages"); + } + registerCleanupFunction(cleanup); + + // Set max pages to 0 to make the download expire. + Services.prefs.setIntPref("places.history.expiration.max_pages", 0); + + let list = await promiseNewList(); + let downloadOne = await promiseNewDownload(); + let downloadTwo = await promiseNewDownload(httpUrl("interruptible.txt")); + + let deferred = Promise.withResolvers(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved(aDownload) { + if (++removeNotifications == 2) { + deferred.resolve(); + } + }, + }; + await list.addView(downloadView); + + // Work with one finished download and one canceled download. + await downloadOne.start(); + downloadTwo.start().catch(() => {}); + await downloadTwo.cancel(); + + // We must replace the visits added while executing the downloads with visits + // that are older than 7 days, otherwise they will not be expired. + await PlacesUtils.history.clear(); + await promiseExpirableDownloadVisit(); + await promiseExpirableDownloadVisit(httpUrl("interruptible.txt")); + + // After clearing history, we can add the downloads to be removed to the list. + await list.add(downloadOne); + await list.add(downloadTwo); + + // Force a history expiration. + Cc["@mozilla.org/places/expiration;1"] + .getService(Ci.nsIObserver) + .observe(null, "places-debug-start-expiration", -1); + + // Wait for both downloads to be removed. + await deferred.promise; + + cleanup(); +}); + +/** + * Checks all downloads are removed after clearing history. + */ +add_task(async function test_history_clear() { + let list = await promiseNewList(); + let downloadOne = await promiseNewDownload(); + let downloadTwo = await promiseNewDownload(); + await list.add(downloadOne); + await list.add(downloadTwo); + + let deferred = Promise.withResolvers(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved(aDownload) { + if (++removeNotifications == 2) { + deferred.resolve(); + } + }, + }; + await list.addView(downloadView); + + await downloadOne.start(); + await downloadTwo.start(); + + await PlacesUtils.history.clear(); + + // Wait for the removal notifications that may still be pending. + await deferred.promise; +}); + +/** + * Tests the removeFinished method to ensure that it only removes + * finished downloads. + */ +add_task(async function test_removeFinished() { + let list = await promiseNewList(); + let downloadOne = await promiseNewDownload(); + let downloadTwo = await promiseNewDownload(); + let downloadThree = await promiseNewDownload(); + let downloadFour = await promiseNewDownload(); + await list.add(downloadOne); + await list.add(downloadTwo); + await list.add(downloadThree); + await list.add(downloadFour); + + let deferred = Promise.withResolvers(); + let removeNotifications = 0; + let downloadView = { + onDownloadRemoved(aDownload) { + Assert.ok( + aDownload == downloadOne || + aDownload == downloadTwo || + aDownload == downloadThree + ); + Assert.ok(removeNotifications < 3); + if (++removeNotifications == 3) { + deferred.resolve(); + } + }, + }; + await list.addView(downloadView); + + // Start three of the downloads, but don't start downloadTwo, then set + // downloadFour to have partial data. All downloads except downloadFour + // should be removed. + await downloadOne.start(); + await downloadThree.start(); + await downloadFour.start(); + downloadFour.hasPartialData = true; + + list.removeFinished(); + await deferred.promise; + + let downloads = await list.getAll(); + Assert.equal(downloads.length, 1); +}); + +/** + * Tests that removeFinished method keeps the file that is currently downloading, + * even if it needs to remove failed download of the same file. + */ +add_task(async function test_removeFinished_keepsDownloadingFile() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + + let oneDownload = await Downloads.createDownload({ + source: httpUrl("empty.txt"), + target: targetFile.path, + }); + + let otherDownload = await Downloads.createDownload({ + source: httpUrl("empty.txt"), + target: targetFile.path, + }); + + let list = await promiseNewList(); + await list.add(oneDownload); + await list.add(otherDownload); + + let deferred = Promise.withResolvers(); + let downloadView = { + async onDownloadRemoved(aDownload) { + Assert.equal(aDownload, oneDownload); + await TestUtils.waitForCondition(() => oneDownload._finalizeExecuted); + deferred.resolve(); + }, + }; + await list.addView(downloadView); + + await oneDownload.start(); + await otherDownload.start(); + + oneDownload.hasPartialData = otherDownload.hasPartialData = true; + oneDownload.error = "Download failed"; + + list.removeFinished(); + await deferred.promise; + + let downloads = await list.getAll(); + Assert.equal( + downloads.length, + 1, + "Failed download should be removed, active download should be kept" + ); + + Assert.ok( + await IOUtils.exists(otherDownload.target.path), + "The file should not have been deleted." + ); +}); + +/** + * Tests the global DownloadSummary objects for the public, private, and + * combined download lists. + */ +add_task(async function test_DownloadSummary() { + mustInterruptResponses(); + + let publicList = await promiseNewList(); + let privateList = await Downloads.getList(Downloads.PRIVATE); + + let publicSummary = await Downloads.getSummary(Downloads.PUBLIC); + let privateSummary = await Downloads.getSummary(Downloads.PRIVATE); + let combinedSummary = await Downloads.getSummary(Downloads.ALL); + + // Add a public download that has succeeded. + let succeededPublicDownload = await promiseNewDownload(); + await succeededPublicDownload.start(); + await publicList.add(succeededPublicDownload); + + // Add a public download that has been canceled midway. + let canceledPublicDownload = await promiseNewDownload( + httpUrl("interruptible.txt") + ); + canceledPublicDownload.start().catch(() => {}); + await promiseDownloadMidway(canceledPublicDownload); + await canceledPublicDownload.cancel(); + await publicList.add(canceledPublicDownload); + + // Add a public download that is in progress. + let inProgressPublicDownload = await promiseNewDownload( + httpUrl("interruptible.txt") + ); + inProgressPublicDownload.start().catch(() => {}); + await promiseDownloadMidway(inProgressPublicDownload); + await publicList.add(inProgressPublicDownload); + + // Add a public download of unknown size that is in progress. + let inProgressSizelessPublicDownload = await promiseNewDownload( + httpUrl("interruptible_nosize.txt") + ); + inProgressSizelessPublicDownload.start().catch(() => {}); + await promiseDownloadStarted(inProgressSizelessPublicDownload); + await publicList.add(inProgressSizelessPublicDownload); + + // Add a private download that is in progress. + let inProgressPrivateDownload = await Downloads.createDownload({ + source: { url: httpUrl("interruptible.txt"), isPrivate: true }, + target: getTempFile(TEST_TARGET_FILE_NAME).path, + }); + inProgressPrivateDownload.start().catch(() => {}); + await promiseDownloadMidway(inProgressPrivateDownload); + await privateList.add(inProgressPrivateDownload); + + // Verify that the summary includes the total number of bytes and the + // currently transferred bytes only for the downloads that are not stopped. + // For simplicity, we assume that after a download is added to the list, its + // current state is immediately propagated to the summary object, which is + // true in the current implementation, though it is not guaranteed as all the + // download operations may happen asynchronously. + Assert.ok(!publicSummary.allHaveStopped); + Assert.ok(!publicSummary.allUnknownSize); + Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length * 3); + Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length * 2); + + Assert.ok(!privateSummary.allHaveStopped); + Assert.ok(!privateSummary.allUnknownSize); + Assert.equal(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + Assert.equal(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + Assert.ok(!combinedSummary.allHaveStopped); + Assert.ok(!combinedSummary.allUnknownSize); + Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 5); + Assert.equal( + combinedSummary.progressCurrentBytes, + TEST_DATA_SHORT.length * 3 + ); + + await inProgressPublicDownload.cancel(); + + // Stopping the download should have excluded it from the summary, but we + // should still have one public download (with unknown size) and also one + // private download remaining. + Assert.ok(!publicSummary.allHaveStopped); + Assert.ok(publicSummary.allUnknownSize); + Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length); + Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + Assert.ok(!privateSummary.allHaveStopped); + Assert.ok(!privateSummary.allUnknownSize); + Assert.equal(privateSummary.progressTotalBytes, TEST_DATA_SHORT.length * 2); + Assert.equal(privateSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + Assert.ok(!combinedSummary.allHaveStopped); + Assert.ok(!combinedSummary.allUnknownSize); + Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length * 3); + Assert.equal( + combinedSummary.progressCurrentBytes, + TEST_DATA_SHORT.length * 2 + ); + + await inProgressPrivateDownload.cancel(); + + // Stopping the private download should have excluded it from the summary, so + // now only the unknown size public download should remain. + Assert.ok(!publicSummary.allHaveStopped); + Assert.ok(publicSummary.allUnknownSize); + Assert.equal(publicSummary.progressTotalBytes, TEST_DATA_SHORT.length); + Assert.equal(publicSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + Assert.ok(privateSummary.allHaveStopped); + Assert.ok(privateSummary.allUnknownSize); + Assert.equal(privateSummary.progressTotalBytes, 0); + Assert.equal(privateSummary.progressCurrentBytes, 0); + + Assert.ok(!combinedSummary.allHaveStopped); + Assert.ok(combinedSummary.allUnknownSize); + Assert.equal(combinedSummary.progressTotalBytes, TEST_DATA_SHORT.length); + Assert.equal(combinedSummary.progressCurrentBytes, TEST_DATA_SHORT.length); + + await inProgressSizelessPublicDownload.cancel(); + + // All the downloads should be stopped now. + Assert.ok(publicSummary.allHaveStopped); + Assert.ok(publicSummary.allUnknownSize); + Assert.equal(publicSummary.progressTotalBytes, 0); + Assert.equal(publicSummary.progressCurrentBytes, 0); + + Assert.ok(privateSummary.allHaveStopped); + Assert.ok(privateSummary.allUnknownSize); + Assert.equal(privateSummary.progressTotalBytes, 0); + Assert.equal(privateSummary.progressCurrentBytes, 0); + + Assert.ok(combinedSummary.allHaveStopped); + Assert.ok(combinedSummary.allUnknownSize); + Assert.equal(combinedSummary.progressTotalBytes, 0); + Assert.equal(combinedSummary.progressCurrentBytes, 0); +}); + +/** + * Checks that views receive the summary change notification. This is tested on + * the combined summary when adding a public download, as we assume that if we + * pass the test in this case we will also pass it in the others. + */ +add_task(async function test_DownloadSummary_notifications() { + let list = await promiseNewList(); + let summary = await Downloads.getSummary(Downloads.ALL); + + let download = await promiseNewDownload(); + await list.add(download); + + // Check that we receive change notifications. + let receivedOnSummaryChanged = false; + await summary.addView({ + onSummaryChanged() { + receivedOnSummaryChanged = true; + }, + }); + await download.start(); + Assert.ok(receivedOnSummaryChanged); +}); + +/** + * Tests that if a new download is added, telemetry records the event and type of the file. + */ +add_task(async function test_downloadAddedTelemetry() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("downloads", true); + + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + + let download = await Downloads.createDownload({ + source: httpUrl("empty.txt"), + target: targetFile.path, + }); + + let list = await Downloads.getList(Downloads.ALL); + await list.add(download); + download.start(); + await promiseDownloadFinished(download); + + TelemetryTestUtils.assertEvents([ + { + category: "downloads", + method: "added", + object: "fileExtension", + value: "txt", + }, + ]); +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadPaths.js b/toolkit/components/downloads/test/unit/test_DownloadPaths.js new file mode 100644 index 0000000000..00fb070669 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadPaths.js @@ -0,0 +1,189 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests for the "DownloadPaths.sys.mjs" JavaScript module. + */ + +function testSanitize(leafName, expectedLeafName, options = {}) { + Assert.equal(DownloadPaths.sanitize(leafName, options), expectedLeafName); +} + +function testSplitBaseNameAndExtension(aLeafName, [aBase, aExt]) { + var [base, ext] = DownloadPaths.splitBaseNameAndExtension(aLeafName); + Assert.equal(base, aBase); + Assert.equal(ext, aExt); + + // If we modify the base name and concatenate it with the extension again, + // another roundtrip through the function should give a consistent result. + // The only exception is when we introduce an extension in a file name that + // didn't have one or that ended with one of the special cases like ".gz". If + // we avoid using a dot and we introduce at least another special character, + // the results are always consistent. + [base, ext] = DownloadPaths.splitBaseNameAndExtension("(" + base + ")" + ext); + Assert.equal(base, "(" + aBase + ")"); + Assert.equal(ext, aExt); +} + +function testCreateNiceUniqueFile(aTempFile, aExpectedLeafName) { + var createdFile = DownloadPaths.createNiceUniqueFile(aTempFile); + Assert.equal(createdFile.leafName, aExpectedLeafName); +} + +add_task(async function test_sanitize() { + // Platform-dependent conversion of special characters to spaces. + const kSpecialChars = 'A:*?|""<<>>;,+=[]B][=+,;>><<""|?*:C'; + if (AppConstants.platform == "android") { + testSanitize(kSpecialChars, "A B C"); + testSanitize(" :: Website :: ", "Website"); + testSanitize("* Website!", "Website!"); + testSanitize("Website | Page!", "Website Page!"); + testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_"); + } else if (AppConstants.platform == "win") { + testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C"); + testSanitize(" :: Website :: ", "Website"); + testSanitize("* Website!", "Website!"); + testSanitize("Website | Page!", "Website Page!"); + testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_"); + } else if (AppConstants.platform == "macosx") { + testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C"); + testSanitize(" :: Website :: ", "Website"); + testSanitize("* Website!", "Website!"); + testSanitize("Website | Page!", "Website Page!"); + testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_"); + } else { + testSanitize(kSpecialChars, "A ;,+=[]B][=+,; C"); + testSanitize(" :: Website :: ", "Website"); + testSanitize("* Website!", "Website!"); + testSanitize("Website | Page!", "Website Page!"); + testSanitize("Directory Listing: /a/b/", "Directory Listing _a_b_"); + } + + // Conversion of consecutive runs of slashes and backslashes to underscores. + testSanitize("\\ \\\\Website\\/Page// /", "_ __Website__Page__ _"); + + // Removal of leading and trailing whitespace and dots after conversion. + testSanitize(" Website ", "Website"); + testSanitize(". . Website . Page . .", "Website . Page"); + testSanitize(" File . txt ", "File . txt"); + testSanitize("\f\n\r\t\v\x00\x1f\x7f\x80\x9f\xa0 . txt", "txt"); + testSanitize("\u1680\u180e\u2000\u2008\u200a . txt", "txt"); + testSanitize("\u2028\u2029\u202f\u205f\u3000\ufeff . txt", "txt"); + + // Strings with whitespace and dots only. + testSanitize(".", ""); + testSanitize("..", ""); + testSanitize(" ", ""); + testSanitize(" . ", ""); + + // Stripping of BIDI formatting characters. + testSanitize("\u200e \u202b\u202c\u202d\u202etest\x7f\u200f", "_ ____test _"); + testSanitize("AB\x7f\u202a\x7f\u202a\x7fCD", "AB _ _ CD"); + + // Stripping of colons: + testSanitize("foo:bar", "foo bar"); + + // not compressing whitespaces. + testSanitize("foo : bar", "foo bar", { compressWhitespaces: false }); + + testSanitize("thing.lnk", "thing.lnk.download"); + testSanitize("thing.lnk\n", "thing.lnk.download"); + testSanitize("thing.lnk", "thing.lnk", { + allowInvalidFilenames: true, + }); + testSanitize("thing.lnk\n", "thing.lnk", { + allowInvalidFilenames: true, + }); + testSanitize("thing.URl", "thing.URl.download"); + testSanitize("thing.URl \n", "thing.URl", { + allowInvalidFilenames: true, + }); + + testSanitize("thing and more .URl", "thing and more .URl", { + allowInvalidFilenames: true, + }); + testSanitize("thing and more .URl ", "thing and more .URl", { + compressWhitespaces: false, + allowInvalidFilenames: true, + }); + + testSanitize("thing.local|", "thing.local.download"); + testSanitize("thing.lo|cal", "thing.lo cal"); + testSanitize('thing.local/*"', "thing.local_"); + + testSanitize("thing.desktoP", "thing.desktoP.download"); + testSanitize("thing.desktoP \n", "thing.desktoP", { + allowInvalidFilenames: true, + }); +}); + +add_task(async function test_splitBaseNameAndExtension() { + // Usual file names. + testSplitBaseNameAndExtension("base", ["base", ""]); + testSplitBaseNameAndExtension("base.ext", ["base", ".ext"]); + testSplitBaseNameAndExtension("base.application", ["base", ".application"]); + testSplitBaseNameAndExtension("base.x.Z", ["base", ".x.Z"]); + testSplitBaseNameAndExtension("base.ext.Z", ["base", ".ext.Z"]); + testSplitBaseNameAndExtension("base.ext.gz", ["base", ".ext.gz"]); + testSplitBaseNameAndExtension("base.ext.Bz2", ["base", ".ext.Bz2"]); + testSplitBaseNameAndExtension("base..ext", ["base.", ".ext"]); + testSplitBaseNameAndExtension("base..Z", ["base.", ".Z"]); + testSplitBaseNameAndExtension("base. .Z", ["base. ", ".Z"]); + testSplitBaseNameAndExtension("base.base.Bz2", ["base.base", ".Bz2"]); + testSplitBaseNameAndExtension("base .ext", ["base ", ".ext"]); + + // Corner cases. A name ending with a dot technically has no extension, but + // we consider the ending dot separately from the base name so that modifying + // the latter never results in an extension being introduced accidentally. + // Names beginning with a dot are hidden files on Unix-like platforms and if + // their name doesn't contain another dot they should have no extension, but + // on Windows the whole name is considered as an extension. + testSplitBaseNameAndExtension("base.", ["base", "."]); + testSplitBaseNameAndExtension(".ext", ["", ".ext"]); + + // Unusual file names (not recommended as input to the function). + testSplitBaseNameAndExtension("base. ", ["base", ". "]); + testSplitBaseNameAndExtension("base ", ["base ", ""]); + testSplitBaseNameAndExtension("", ["", ""]); + testSplitBaseNameAndExtension(" ", [" ", ""]); + testSplitBaseNameAndExtension(" . ", [" ", ". "]); + testSplitBaseNameAndExtension(" .. ", [" .", ". "]); + testSplitBaseNameAndExtension(" .ext", [" ", ".ext"]); + testSplitBaseNameAndExtension(" .ext. ", [" .ext", ". "]); + testSplitBaseNameAndExtension(" .ext.gz ", [" .ext", ".gz "]); +}); + +add_task(async function test_createNiceUniqueFile() { + var destDir = FileTestUtils.getTempFile("destdir"); + destDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + // Single extension. + var tempFile = destDir.clone(); + tempFile.append("test.txt"); + testCreateNiceUniqueFile(tempFile, "test.txt"); + testCreateNiceUniqueFile(tempFile, "test(1).txt"); + testCreateNiceUniqueFile(tempFile, "test(2).txt"); + + // Double extension. + tempFile.leafName = "test.tar.gz"; + testCreateNiceUniqueFile(tempFile, "test.tar.gz"); + testCreateNiceUniqueFile(tempFile, "test(1).tar.gz"); + testCreateNiceUniqueFile(tempFile, "test(2).tar.gz"); + + // Test automatic shortening of long file names. We don't know exactly how + // many characters are removed, because it depends on the name of the folder + // where the file is located. + tempFile.leafName = new Array(256).join("T") + ".txt"; + var newFile = DownloadPaths.createNiceUniqueFile(tempFile); + Assert.ok(newFile.leafName.length < tempFile.leafName.length); + Assert.equal(newFile.leafName.slice(-4), ".txt"); + + // Creating a valid file name from an invalid one is not always possible. + tempFile.append("file-under-long-directory.txt"); + try { + DownloadPaths.createNiceUniqueFile(tempFile); + do_throw("Exception expected with a long parent directory name."); + } catch (e) { + // An exception is expected, but we don't know which one exactly. + } +}); diff --git a/toolkit/components/downloads/test/unit/test_DownloadStore.js b/toolkit/components/downloads/test/unit/test_DownloadStore.js new file mode 100644 index 0000000000..9353b63060 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_DownloadStore.js @@ -0,0 +1,458 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the DownloadStore object. + */ + +"use strict"; + +// Globals + +ChromeUtils.defineESModuleGetters(this, { + DownloadError: "resource://gre/modules/DownloadCore.sys.mjs", + DownloadStore: "resource://gre/modules/DownloadStore.sys.mjs", +}); + +/** + * Returns a new DownloadList object with an associated DownloadStore. + * + * @param aStorePath + * String pointing to the file to be associated with the DownloadStore, + * or undefined to use a non-existing temporary file. In this case, the + * temporary file is deleted when the test file execution finishes. + * + * @return {Promise} + * @resolves Array [ Newly created DownloadList , associated DownloadStore ]. + * @rejects JavaScript exception. + */ +function promiseNewListAndStore(aStorePath) { + return promiseNewList().then(function (aList) { + let path = aStorePath || getTempFile(TEST_STORE_FILE_NAME).path; + let store = new DownloadStore(aList, path); + return [aList, store]; + }); +} + +// Tests + +/** + * Saves downloads to a file, then reloads them. + */ +add_task(async function test_save_reload() { + let [listForSave, storeForSave] = await promiseNewListAndStore(); + let [listForLoad, storeForLoad] = await promiseNewListAndStore( + storeForSave.path + ); + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + + listForSave.add(await promiseNewDownload(httpUrl("source.txt"))); + listForSave.add( + await Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), referrerInfo }, + target: getTempFile(TEST_TARGET_FILE_NAME), + }) + ); + + // If we used a callback to adjust the channel, the download should + // not be serialized because we can't recreate it across sessions. + let adjustedDownload = await Downloads.createDownload({ + source: { + url: httpUrl("empty.txt"), + adjustChannel: () => Promise.resolve(), + }, + target: getTempFile(TEST_TARGET_FILE_NAME), + }); + listForSave.add(adjustedDownload); + + let legacyDownload = await promiseStartLegacyDownload(); + await legacyDownload.cancel(); + listForSave.add(legacyDownload); + + await storeForSave.save(); + await storeForLoad.load(); + + // Remove the adjusted download because it should not appear here. + listForSave.remove(adjustedDownload); + + let itemsForSave = await listForSave.getAll(); + let itemsForLoad = await listForLoad.getAll(); + + Assert.equal(itemsForSave.length, itemsForLoad.length); + + // Downloads should be reloaded in the same order. + for (let i = 0; i < itemsForSave.length; i++) { + // The reloaded downloads are different objects. + Assert.notEqual(itemsForSave[i], itemsForLoad[i]); + + // The reloaded downloads have the same properties. + Assert.equal(itemsForSave[i].source.url, itemsForLoad[i].source.url); + Assert.equal( + !!itemsForSave[i].source.referrerInfo, + !!itemsForLoad[i].source.referrerInfo + ); + if ( + itemsForSave[i].source.referrerInfo && + itemsForLoad[i].source.referrerInfo + ) { + Assert.ok( + itemsForSave[i].source.referrerInfo.equals( + itemsForLoad[i].source.referrerInfo + ) + ); + } + Assert.equal(itemsForSave[i].target.path, itemsForLoad[i].target.path); + Assert.equal( + itemsForSave[i].saver.toSerializable(), + itemsForLoad[i].saver.toSerializable() + ); + } +}); + +/** + * Checks that saving an empty list deletes any existing file. + */ +add_task(async function test_save_empty() { + let [, store] = await promiseNewListAndStore(); + + await IOUtils.write(store.path, new Uint8Array()); + + await store.save(); + + let successful; + try { + await IOUtils.read(store.path); + successful = true; + } catch (ex) { + successful = ex.name != "NotFoundError"; + } + + ok(!successful, "File should not exist"); + + // If the file does not exist, saving should not generate exceptions. + await store.save(); +}); + +/** + * Checks that loading from a missing file results in an empty list. + */ +add_task(async function test_load_empty() { + let [list, store] = await promiseNewListAndStore(); + + let succeesful; + try { + await IOUtils.read(store.path); + succeesful = true; + } catch (ex) { + succeesful = ex.name != "NotFoundError"; + } + + ok(!succeesful, "File should not exist"); + + let items = await list.getAll(); + Assert.equal(items.length, 0); +}); + +/** + * Loads downloads from a string in a predefined format. The purpose of this + * test is to verify that the JSON format used in previous versions can be + * loaded, assuming the file is reloaded on the same platform. + */ +add_task(async function test_load_string_predefined() { + let [list, store] = await promiseNewListAndStore(); + + // The platform-dependent file name should be generated dynamically. + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let filePathLiteral = JSON.stringify(targetPath); + let sourceUriLiteral = JSON.stringify(httpUrl("source.txt")); + let emptyUriLiteral = JSON.stringify(httpUrl("empty.txt")); + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + let referrerInfoLiteral = JSON.stringify( + E10SUtils.serializeReferrerInfo(referrerInfo) + ); + + let string = + '{"list":[{"source":' + + sourceUriLiteral + + "," + + '"target":' + + filePathLiteral + + "}," + + '{"source":{"url":' + + emptyUriLiteral + + "," + + '"referrerInfo":' + + referrerInfoLiteral + + "}," + + '"target":' + + filePathLiteral + + "}]}"; + + await IOUtils.write(store.path, new TextEncoder().encode(string), { + tmpPath: store.path + ".tmp", + }); + + await store.load(); + + let items = await list.getAll(); + + Assert.equal(items.length, 2); + + Assert.equal(items[0].source.url, httpUrl("source.txt")); + Assert.equal(items[0].target.path, targetPath); + + Assert.equal(items[1].source.url, httpUrl("empty.txt")); + + checkEqualReferrerInfos(items[1].source.referrerInfo, referrerInfo); + Assert.equal(items[1].target.path, targetPath); +}); + +/** + * Loads downloads from a well-formed JSON string containing unrecognized data. + */ +add_task(async function test_load_string_unrecognized() { + let [list, store] = await promiseNewListAndStore(); + + // The platform-dependent file name should be generated dynamically. + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let filePathLiteral = JSON.stringify(targetPath); + let sourceUriLiteral = JSON.stringify(httpUrl("source.txt")); + + let string = + '{"list":[{"source":null,' + + '"target":null},' + + '{"source":{"url":' + + sourceUriLiteral + + "}," + + '"target":{"path":' + + filePathLiteral + + "}," + + '"saver":{"type":"copy"}}]}'; + + await IOUtils.write(store.path, new TextEncoder().encode(string), { + tmpPath: store.path + ".tmp", + }); + + await store.load(); + + let items = await list.getAll(); + + Assert.equal(items.length, 1); + + Assert.equal(items[0].source.url, httpUrl("source.txt")); + Assert.equal(items[0].target.path, targetPath); +}); + +/** + * Loads downloads from a malformed JSON string. + */ +add_task(async function test_load_string_malformed() { + let [list, store] = await promiseNewListAndStore(); + + let string = + '{"list":[{"source":null,"target":null},' + + '{"source":{"url":"about:blank"}}}'; + + await IOUtils.write(store.path, new TextEncoder().encode(string), { + tmpPath: store.path + ".tmp", + }); + + try { + await store.load(); + do_throw("Exception expected when JSON data is malformed."); + } catch (ex) { + if (ex.name != "SyntaxError") { + throw ex; + } + info("The expected SyntaxError exception was thrown."); + } + + let items = await list.getAll(); + + Assert.equal(items.length, 0); +}); + +/** + * Saves downloads with unknown properties to a file and then reloads + * them to ensure that these properties are preserved. + */ +add_task(async function test_save_reload_unknownProperties() { + let [listForSave, storeForSave] = await promiseNewListAndStore(); + let [listForLoad, storeForLoad] = await promiseNewListAndStore( + storeForSave.path + ); + + let download1 = await promiseNewDownload(httpUrl("source.txt")); + // startTime should be ignored as it is a known property, and error + // is ignored by serialization + download1._unknownProperties = { + peanut: "butter", + orange: "marmalade", + startTime: 77, + error: { message: "Passed" }, + }; + listForSave.add(download1); + + let download2 = await promiseStartLegacyDownload(); + await download2.cancel(); + download2._unknownProperties = { number: 5, object: { test: "string" } }; + listForSave.add(download2); + + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + let download3 = await Downloads.createDownload({ + source: { + url: httpUrl("empty.txt"), + referrerInfo, + source1: "download3source1", + source2: "download3source2", + }, + target: { + path: getTempFile(TEST_TARGET_FILE_NAME).path, + target1: "download3target1", + target2: "download3target2", + }, + saver: { + type: "copy", + saver1: "download3saver1", + saver2: "download3saver2", + }, + }); + listForSave.add(download3); + + await storeForSave.save(); + await storeForLoad.load(); + + let itemsForSave = await listForSave.getAll(); + let itemsForLoad = await listForLoad.getAll(); + + Assert.equal(itemsForSave.length, itemsForLoad.length); + + Assert.equal(Object.keys(itemsForLoad[0]._unknownProperties).length, 2); + Assert.equal(itemsForLoad[0]._unknownProperties.peanut, "butter"); + Assert.equal(itemsForLoad[0]._unknownProperties.orange, "marmalade"); + Assert.equal(false, "startTime" in itemsForLoad[0]._unknownProperties); + Assert.equal(false, "error" in itemsForLoad[0]._unknownProperties); + + Assert.equal(Object.keys(itemsForLoad[1]._unknownProperties).length, 2); + Assert.equal(itemsForLoad[1]._unknownProperties.number, 5); + Assert.equal(itemsForLoad[1]._unknownProperties.object.test, "string"); + + Assert.equal( + Object.keys(itemsForLoad[2].source._unknownProperties).length, + 2 + ); + Assert.equal( + itemsForLoad[2].source._unknownProperties.source1, + "download3source1" + ); + Assert.equal( + itemsForLoad[2].source._unknownProperties.source2, + "download3source2" + ); + + Assert.equal( + Object.keys(itemsForLoad[2].target._unknownProperties).length, + 2 + ); + Assert.equal( + itemsForLoad[2].target._unknownProperties.target1, + "download3target1" + ); + Assert.equal( + itemsForLoad[2].target._unknownProperties.target2, + "download3target2" + ); + + Assert.equal(Object.keys(itemsForLoad[2].saver._unknownProperties).length, 2); + Assert.equal( + itemsForLoad[2].saver._unknownProperties.saver1, + "download3saver1" + ); + Assert.equal( + itemsForLoad[2].saver._unknownProperties.saver2, + "download3saver2" + ); +}); + +/** + * Saves insecure downloads to a file, then reloads the file and checks if they + * are still there. + */ +add_task(async function test_insecure_download_deletion() { + let [listForSave, storeForSave] = await promiseNewListAndStore(); + let [listForLoad, storeForLoad] = await promiseNewListAndStore( + storeForSave.path + ); + let referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + NetUtil.newURI(TEST_REFERRER_URL) + ); + + const createTestDownload = async startTime => { + // Create a valid test download and start it so it creates a file + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + let download = await Downloads.createDownload({ + source: { url: httpUrl("empty.txt"), referrerInfo }, + target: targetFile.path, + startTime: new Date().toISOString(), + contentType: "application/zip", + }); + await download.start(); + + // Add an "Insecure Download" error and overwrite the start time for + // serialization + download.hasBlockedData = true; + download.error = DownloadError.fromSerializable({ + becauseBlockedByReputationCheck: true, + reputationCheckVerdict: "Insecure", + }); + download.startTime = startTime; + + let targetPath = download.target.path; + + // Add download to store, save, load and retrieve deserialized download list + listForSave.add(download); + await storeForSave.save(); + await storeForLoad.load(); + let loadedDownloadList = await listForLoad.getAll(); + + return [loadedDownloadList, targetPath]; + }; + + // Insecure downloads that are older than 5 minutes should get removed from + // the download-store and the file should get deleted. (360000 = 6 minutes) + let [loadedDownloadList1, targetPath1] = await createTestDownload( + new Date(Date.now() - 360000) + ); + + Assert.equal(loadedDownloadList1.length, 0, "Download should be removed"); + Assert.ok( + !(await IOUtils.exists(targetPath1)), + "The file should have been deleted." + ); + + // Insecure downloads that are newer than 5 minutes should stay in the + // download store and the file should remain. + let [loadedDownloadList2, targetPath2] = await createTestDownload(new Date()); + + Assert.equal(loadedDownloadList2.length, 1, "Download should be kept"); + Assert.ok( + await IOUtils.exists(targetPath2), + "The file should have not been deleted." + ); +}); diff --git a/toolkit/components/downloads/test/unit/test_Download_noext_win.js b/toolkit/components/downloads/test/unit/test_Download_noext_win.js new file mode 100644 index 0000000000..0e226229f6 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_Download_noext_win.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + info("Get a file without extension"); + let noExtFile = getTempFile("test_bug_1661365"); + Assert.ok(!noExtFile.leafName.includes("."), "Sanity check the filename"); + info("Create an exe file with the same name"); + await IOUtils.remove(noExtFile.path + ".exe", { ignoreAbsent: true }); + let exeFile = new FileUtils.File(noExtFile.path + ".exe"); + exeFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + Assert.ok(await fileExists(exeFile.path), "Sanity check the exe exists."); + Assert.equal( + exeFile.leafName, + noExtFile.leafName + ".exe", + "Sanity check the file names." + ); + registerCleanupFunction(async function () { + await IOUtils.remove(noExtFile.path, { ignoreAbsent: true }); + await IOUtils.remove(exeFile.path, { ignoreAbsent: true }); + }); + + info("Download to the no-extension file"); + let download = await Downloads.createDownload({ + source: httpUrl("source.txt"), + target: noExtFile, + }); + await download.start(); + + Assert.ok( + await fileExists(download.target.path), + "The file should have been created." + ); + Assert.ok(await fileExists(exeFile.path), "Sanity check the exe exists."); + + info("Launch should open the containing folder"); + let promiseShowInFolder = waitForDirectoryShown(); + download.launch(); + Assert.equal(await promiseShowInFolder, noExtFile.path); +}); + +/** + * Waits for an attempt to show the directory where a file is located, and + * returns the path of the file. + */ +function waitForDirectoryShown() { + return new Promise(resolve => { + let waitFn = base => ({ + showContainingDirectory(path) { + Integration.downloads.unregister(waitFn); + resolve(path); + return Promise.resolve(); + }, + }); + Integration.downloads.register(waitFn); + }); +} diff --git a/toolkit/components/downloads/test/unit/test_Downloads.js b/toolkit/components/downloads/test/unit/test_Downloads.js new file mode 100644 index 0000000000..b99f823008 --- /dev/null +++ b/toolkit/components/downloads/test/unit/test_Downloads.js @@ -0,0 +1,153 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the functions located directly in the "Downloads" object. + */ + +"use strict"; + +// Tests + +/** + * Tests that the createDownload function exists and can be called. More + * detailed tests are implemented separately for the DownloadCore module. + */ +add_task(async function test_createDownload() { + // Creates a simple Download object without starting the download. + await Downloads.createDownload({ + source: { url: "about:blank" }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + saver: { type: "copy" }, + }); +}); + +/** + * Tests createDownload for private download. + */ +add_task(async function test_createDownload_private() { + let download = await Downloads.createDownload({ + source: { url: "about:blank", isPrivate: true }, + target: { path: getTempFile(TEST_TARGET_FILE_NAME).path }, + saver: { type: "copy" }, + }); + Assert.ok(download.source.isPrivate); +}); + +/** + * Tests createDownload for normal (public) download. + */ +add_task(async function test_createDownload_public() { + let tempPath = getTempFile(TEST_TARGET_FILE_NAME).path; + let download = await Downloads.createDownload({ + source: { url: "about:blank", isPrivate: false }, + target: { path: tempPath }, + saver: { type: "copy" }, + }); + Assert.ok(!download.source.isPrivate); + + download = await Downloads.createDownload({ + source: { url: "about:blank" }, + target: { path: tempPath }, + saver: { type: "copy" }, + }); + Assert.ok(!download.source.isPrivate); +}); + +/** + * Tests "fetch" with nsIURI and nsIFile as arguments. + */ +add_task(async function test_fetch_uri_file_arguments() { + let targetFile = getTempFile(TEST_TARGET_FILE_NAME); + await Downloads.fetch(NetUtil.newURI(httpUrl("source.txt")), targetFile); + await promiseVerifyContents(targetFile.path, TEST_DATA_SHORT); +}); + +/** + * Tests "fetch" with DownloadSource and DownloadTarget as arguments. + */ +add_task(async function test_fetch_object_arguments() { + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + await Downloads.fetch({ url: httpUrl("source.txt") }, { path: targetPath }); + await promiseVerifyContents(targetPath, TEST_DATA_SHORT); +}); + +/** + * Tests "fetch" with string arguments. + */ +add_task(async function test_fetch_string_arguments() { + let targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + await Downloads.fetch(httpUrl("source.txt"), targetPath); + await promiseVerifyContents(targetPath, TEST_DATA_SHORT); + + targetPath = getTempFile(TEST_TARGET_FILE_NAME).path; + await Downloads.fetch(httpUrl("source.txt"), targetPath); + await promiseVerifyContents(targetPath, TEST_DATA_SHORT); +}); + +/** + * Tests that the getList function returns the same list when called multiple + * times with the same argument, but returns different lists when called with + * different arguments. More detailed tests are implemented separately for the + * DownloadList module. + */ +add_task(async function test_getList() { + let publicListOne = await Downloads.getList(Downloads.PUBLIC); + let privateListOne = await Downloads.getList(Downloads.PRIVATE); + + let publicListTwo = await Downloads.getList(Downloads.PUBLIC); + let privateListTwo = await Downloads.getList(Downloads.PRIVATE); + + Assert.equal(publicListOne, publicListTwo); + Assert.equal(privateListOne, privateListTwo); + + Assert.notEqual(publicListOne, privateListOne); +}); + +/** + * Tests that the getSummary function returns the same summary when called + * multiple times with the same argument, but returns different summaries when + * called with different arguments. More detailed tests are implemented + * separately for the DownloadSummary object in the DownloadList module. + */ +add_task(async function test_getSummary() { + let publicSummaryOne = await Downloads.getSummary(Downloads.PUBLIC); + let privateSummaryOne = await Downloads.getSummary(Downloads.PRIVATE); + + let publicSummaryTwo = await Downloads.getSummary(Downloads.PUBLIC); + let privateSummaryTwo = await Downloads.getSummary(Downloads.PRIVATE); + + Assert.equal(publicSummaryOne, publicSummaryTwo); + Assert.equal(privateSummaryOne, privateSummaryTwo); + + Assert.notEqual(publicSummaryOne, privateSummaryOne); +}); + +/** + * Tests that the getSystemDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(async function test_getSystemDownloadsDirectory() { + let downloadDir = await Downloads.getSystemDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); +}); + +/** + * Tests that the getPreferredDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(async function test_getPreferredDownloadsDirectory() { + let downloadDir = await Downloads.getPreferredDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); +}); + +/** + * Tests that the getTemporaryDownloadsDirectory returns a non-empty download + * directory string. + */ +add_task(async function test_getTemporaryDownloadsDirectory() { + let downloadDir = await Downloads.getTemporaryDownloadsDirectory(); + Assert.notEqual(downloadDir, ""); +}); diff --git a/toolkit/components/downloads/test/unit/xpcshell.toml b/toolkit/components/downloads/test/unit/xpcshell.toml new file mode 100644 index 0000000000..073a4a8eeb --- /dev/null +++ b/toolkit/components/downloads/test/unit/xpcshell.toml @@ -0,0 +1,33 @@ +[DEFAULT] +head = "head.js" +skip-if = ["os == 'android'"] + +# Note: The "tail.js" file is not defined in the "tail" key because it calls +# the "add_test_task" function, that does not work properly in tail files. +support-files = ["common_test_Download.js"] + +["test_DownloadBlockedTelemetry.js"] + +["test_DownloadCore.js"] + +["test_DownloadHistory.js"] + +["test_DownloadHistory_initialization.js"] + +["test_DownloadHistory_initialization2.js"] + +["test_DownloadIntegration.js"] + +["test_DownloadLegacy.js"] +run-sequentially = "very high failure rate in parallel" + +["test_DownloadList.js"] + +["test_DownloadPaths.js"] + +["test_DownloadStore.js"] + +["test_Download_noext_win.js"] +run-if = ["os == 'win'"] # Windows-specific test. + +["test_Downloads.js"] |