/* -*- 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 file tests components that implement nsIBackgroundFileSaver. */ //////////////////////////////////////////////////////////////////////////////// //// Globals "use strict"; ChromeUtils.defineESModuleGetters(this, { FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs", }); const BackgroundFileSaverOutputStream = Components.Constructor( "@mozilla.org/network/background-file-saver;1?mode=outputstream", "nsIBackgroundFileSaver" ); const BackgroundFileSaverStreamListener = Components.Constructor( "@mozilla.org/network/background-file-saver;1?mode=streamlistener", "nsIBackgroundFileSaver" ); const StringInputStream = Components.Constructor( "@mozilla.org/io/string-input-stream;1", "nsIStringInputStream", "setData" ); const REQUEST_SUSPEND_AT = 1024 * 1024 * 4; const TEST_DATA_SHORT = "This test string is written to the file."; const TEST_FILE_NAME_1 = "test-backgroundfilesaver-1.txt"; const TEST_FILE_NAME_2 = "test-backgroundfilesaver-2.txt"; const TEST_FILE_NAME_3 = "test-backgroundfilesaver-3.txt"; // A map of test data length to the expected SHA-256 hashes const EXPECTED_HASHES = { // No data 0: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // TEST_DATA_SHORT 40: "f37176b690e8744ee990a206c086cba54d1502aa2456c3b0c84ef6345d72a192", // TEST_DATA_SHORT + TEST_DATA_SHORT 80: "780c0e91f50bb7ec922cc11e16859e6d5df283c0d9470f61772e3d79f41eeb58", // TEST_DATA_LONG 4718592: "372cb9e5ce7b76d3e2a5042e78aa72dcf973e659a262c61b7ff51df74b36767b", // TEST_DATA_LONG + TEST_DATA_LONG 9437184: "693e4f8c6855a6fed4f5f9370d12cc53105672f3ff69783581e7d925984c41d3", }; // Generate a long string of data in a moderately fast way. const TEST_256_CHARS = new Array(257).join("-"); const DESIRED_LENGTH = REQUEST_SUSPEND_AT * 1.125; const TEST_DATA_LONG = new Array(1 + DESIRED_LENGTH / 256).join(TEST_256_CHARS); Assert.equal(TEST_DATA_LONG.length, DESIRED_LENGTH); /** * 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); } /** * Helper function for converting a binary blob to its hex equivalent. * * @param str * String possibly containing non-printable chars. * @return A hex-encoded string. */ function toHex(str) { var hex = ""; for (var i = 0; i < str.length; i++) { hex += ("0" + str.charCodeAt(i).toString(16)).slice(-2); } return hex; } /** * Ensures that the given file contents are equal to the given string. * * @param aFile * nsIFile 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. */ function promiseVerifyContents(aFile, aExpectedContents) { return new Promise(resolve => { NetUtil.asyncFetch( { uri: NetUtil.newURI(aFile), 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) { Assert.equal(contents, aExpectedContents); } else { // Do not print the entire content string to the test log. Assert.equal(contents.length, aExpectedContents.length); Assert.ok(contents == aExpectedContents); } resolve(); } ); }); } /** * Waits for the given saver object to complete. * * @param aSaver * The saver, with the output stream or a stream listener implementation. * @param aOnTargetChangeFn * Optional callback invoked with the target file name when it changes. * * @return {Promise} * @resolves When onSaveComplete is called with a success code. * @rejects With an exception, if onSaveComplete is called with a failure code. */ function promiseSaverComplete(aSaver, aOnTargetChangeFn) { return new Promise((resolve, reject) => { aSaver.observer = { onTargetChange: function BFSO_onSaveComplete(saver, aTarget) { if (aOnTargetChangeFn) { aOnTargetChangeFn(aTarget); } }, onSaveComplete: function BFSO_onSaveComplete(saver, aStatus) { if (Components.isSuccessCode(aStatus)) { resolve(); } else { reject(new Components.Exception("Saver failed.", aStatus)); } }, }; }); } /** * Feeds a string to a BackgroundFileSaverOutputStream. * * @param aSourceString * The source data to copy. * @param aSaverOutputStream * The BackgroundFileSaverOutputStream to feed. * @param aCloseWhenDone * If true, the output stream will be closed when the copy finishes. * * @return {Promise} * @resolves When the copy completes with a success code. * @rejects With an exception, if the copy fails. */ function promiseCopyToSaver(aSourceString, aSaverOutputStream, aCloseWhenDone) { return new Promise((resolve, reject) => { let inputStream = new StringInputStream( aSourceString, aSourceString.length ); let copier = Cc[ "@mozilla.org/network/async-stream-copier;1" ].createInstance(Ci.nsIAsyncStreamCopier); copier.init( inputStream, aSaverOutputStream, null, false, true, 0x8000, true, aCloseWhenDone ); copier.asyncCopy( { onStartRequest() {}, onStopRequest(aRequest, aStatusCode) { if (Components.isSuccessCode(aStatusCode)) { resolve(); } else { reject(new Components.Exception(aStatusCode)); } }, }, null ); }); } /** * Feeds a string to a BackgroundFileSaverStreamListener. * * @param aSourceString * The source data to copy. * @param aSaverStreamListener * The BackgroundFileSaverStreamListener to feed. * @param aCloseWhenDone * If true, the output stream will be closed when the copy finishes. * * @return {Promise} * @resolves When the operation completes with a success code. * @rejects With an exception, if the operation fails. */ function promisePumpToSaver(aSourceString, aSaverStreamListener) { return new Promise((resolve, reject) => { aSaverStreamListener.QueryInterface(Ci.nsIStreamListener); let inputStream = new StringInputStream( aSourceString, aSourceString.length ); let pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance( Ci.nsIInputStreamPump ); pump.init(inputStream, 0, 0, true); pump.asyncRead({ onStartRequest: function PPTS_onStartRequest(aRequest) { aSaverStreamListener.onStartRequest(aRequest); }, onStopRequest: function PPTS_onStopRequest(aRequest, aStatusCode) { aSaverStreamListener.onStopRequest(aRequest, aStatusCode); if (Components.isSuccessCode(aStatusCode)) { resolve(); } else { reject(new Components.Exception(aStatusCode)); } }, onDataAvailable: function PPTS_onDataAvailable( aRequest, aInputStream, aOffset, aCount ) { aSaverStreamListener.onDataAvailable( aRequest, aInputStream, aOffset, aCount ); }, }); }); } var gStillRunning = true; //////////////////////////////////////////////////////////////////////////////// //// Tests add_task(function test_setup() { // Wait 10 minutes, that is half of the external xpcshell timeout. do_timeout(10 * 60 * 1000, function () { if (gStillRunning) { do_throw("Test timed out."); } }); }); add_task(async function test_normal() { // This test demonstrates the most basic use case. let destFile = getTempFile(TEST_FILE_NAME_1); // Create the object implementing the output stream. let saver = new BackgroundFileSaverOutputStream(); // Set up callbacks for completion and target file name change. let receivedOnTargetChange = false; function onTargetChange(aTarget) { Assert.ok(destFile.equals(aTarget)); receivedOnTargetChange = true; } let completionPromise = promiseSaverComplete(saver, onTargetChange); // Set the target file. saver.setTarget(destFile, false); // Write some data and close the output stream. await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); // Indicate that we are ready to finish, and wait for a successful callback. saver.finish(Cr.NS_OK); await completionPromise; // Only after we receive the completion notification, we can also be sure that // we've received the target file name change notification before it. Assert.ok(receivedOnTargetChange); // Clean up. destFile.remove(false); }); add_task(async function test_combinations() { let initialFile = getTempFile(TEST_FILE_NAME_1); let renamedFile = getTempFile(TEST_FILE_NAME_2); // Keep track of the current file. let currentFile = null; function onTargetChange(aTarget) { currentFile = null; info("Target file changed to: " + aTarget.leafName); currentFile = aTarget; } // Tests various combinations of events and behaviors for both the stream // listener and the output stream implementations. for (let testFlags = 0; testFlags < 32; testFlags++) { let keepPartialOnFailure = !!(testFlags & 1); let renameAtSomePoint = !!(testFlags & 2); let cancelAtSomePoint = !!(testFlags & 4); let useStreamListener = !!(testFlags & 8); let useLongData = !!(testFlags & 16); let startTime = Date.now(); info( "Starting keepPartialOnFailure = " + keepPartialOnFailure + ", renameAtSomePoint = " + renameAtSomePoint + ", cancelAtSomePoint = " + cancelAtSomePoint + ", useStreamListener = " + useStreamListener + ", useLongData = " + useLongData ); // Create the object and register the observers. currentFile = null; let saver = useStreamListener ? new BackgroundFileSaverStreamListener() : new BackgroundFileSaverOutputStream(); saver.enableSha256(); let completionPromise = promiseSaverComplete(saver, onTargetChange); // Start feeding the first chunk of data to the saver. In case we are using // the stream listener, we only write one chunk. let testData = useLongData ? TEST_DATA_LONG : TEST_DATA_SHORT; let feedPromise = useStreamListener ? promisePumpToSaver(testData + testData, saver) : promiseCopyToSaver(testData, saver, false); // Set a target output file. saver.setTarget(initialFile, keepPartialOnFailure); // Wait for the first chunk of data to be copied. await feedPromise; if (renameAtSomePoint) { saver.setTarget(renamedFile, keepPartialOnFailure); } if (cancelAtSomePoint) { saver.finish(Cr.NS_ERROR_FAILURE); } // Feed the second chunk of data to the saver. if (!useStreamListener) { await promiseCopyToSaver(testData, saver, true); } // Wait for completion, and ensure we succeeded or failed as expected. if (!cancelAtSomePoint) { saver.finish(Cr.NS_OK); } try { await completionPromise; if (cancelAtSomePoint) { do_throw("Failure expected."); } } catch (ex) { if (!cancelAtSomePoint || ex.result != Cr.NS_ERROR_FAILURE) { throw ex; } } if (!cancelAtSomePoint) { // In this case, the file must exist. Assert.ok(currentFile.exists()); let expectedContents = testData + testData; await promiseVerifyContents(currentFile, expectedContents); Assert.equal( EXPECTED_HASHES[expectedContents.length], toHex(saver.sha256Hash) ); currentFile.remove(false); // If the target was really renamed, the old file should not exist. if (renamedFile.equals(currentFile)) { Assert.ok(!initialFile.exists()); } } else if (!keepPartialOnFailure) { // In this case, the file must not exist. Assert.ok(!initialFile.exists()); Assert.ok(!renamedFile.exists()); } else { // In this case, the file may or may not exist, because canceling can // interrupt the asynchronous operation at any point, even before the file // has been created for the first time. if (initialFile.exists()) { initialFile.remove(false); } if (renamedFile.exists()) { renamedFile.remove(false); } } info("Test case completed in " + (Date.now() - startTime) + " ms."); } }); add_task(async function test_setTarget_after_close_stream() { // This test checks the case where we close the output stream before we call // the setTarget method. All the data should be buffered and written anyway. let destFile = getTempFile(TEST_FILE_NAME_1); // Test the case where the file does not already exists first, then the case // where the file already exists. for (let i = 0; i < 2; i++) { let saver = new BackgroundFileSaverOutputStream(); saver.enableSha256(); let completionPromise = promiseSaverComplete(saver); // Copy some data to the output stream of the file saver. This data must // be shorter than the internal component's pipe buffer for the test to // succeed, because otherwise the test would block waiting for the write to // complete. await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); // Set the target file and wait for the output to finish. saver.setTarget(destFile, false); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. await promiseVerifyContents(destFile, TEST_DATA_SHORT); Assert.equal( EXPECTED_HASHES[TEST_DATA_SHORT.length], toHex(saver.sha256Hash) ); } // Clean up. destFile.remove(false); }); add_task(async function test_setTarget_fast() { // This test checks a fast rename of the target file. let destFile1 = getTempFile(TEST_FILE_NAME_1); let destFile2 = getTempFile(TEST_FILE_NAME_2); let saver = new BackgroundFileSaverOutputStream(); let completionPromise = promiseSaverComplete(saver); // Set the initial name after the stream is closed, then rename immediately. await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); saver.setTarget(destFile1, false); saver.setTarget(destFile2, false); // Wait for all the operations to complete. saver.finish(Cr.NS_OK); await completionPromise; // Verify results and clean up. Assert.ok(!destFile1.exists()); await promiseVerifyContents(destFile2, TEST_DATA_SHORT); destFile2.remove(false); }); add_task(async function test_setTarget_multiple() { // This test checks multiple renames of the target file. let destFile = getTempFile(TEST_FILE_NAME_1); let saver = new BackgroundFileSaverOutputStream(); let completionPromise = promiseSaverComplete(saver); // Rename both before and after the stream is closed. saver.setTarget(getTempFile(TEST_FILE_NAME_2), false); saver.setTarget(getTempFile(TEST_FILE_NAME_3), false); await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); saver.setTarget(getTempFile(TEST_FILE_NAME_2), false); saver.setTarget(destFile, false); // Wait for all the operations to complete. saver.finish(Cr.NS_OK); await completionPromise; // Verify results and clean up. Assert.ok(!getTempFile(TEST_FILE_NAME_2).exists()); Assert.ok(!getTempFile(TEST_FILE_NAME_3).exists()); await promiseVerifyContents(destFile, TEST_DATA_SHORT); destFile.remove(false); }); add_task(async function test_enableAppend() { // This test checks append mode with hashing disabled. let destFile = getTempFile(TEST_FILE_NAME_1); // Test the case where the file does not already exists first, then the case // where the file already exists. for (let i = 0; i < 2; i++) { let saver = new BackgroundFileSaverOutputStream(); saver.enableAppend(); let completionPromise = promiseSaverComplete(saver); saver.setTarget(destFile, false); await promiseCopyToSaver(TEST_DATA_LONG, saver, true); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. let expectedContents = i == 0 ? TEST_DATA_LONG : TEST_DATA_LONG + TEST_DATA_LONG; await promiseVerifyContents(destFile, expectedContents); } // Clean up. destFile.remove(false); }); add_task(async function test_enableAppend_setTarget_fast() { // This test checks a fast rename of the target file in append mode. let destFile1 = getTempFile(TEST_FILE_NAME_1); let destFile2 = getTempFile(TEST_FILE_NAME_2); // Test the case where the file does not already exists first, then the case // where the file already exists. for (let i = 0; i < 2; i++) { let saver = new BackgroundFileSaverOutputStream(); saver.enableAppend(); let completionPromise = promiseSaverComplete(saver); await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); // The first time, we start appending to the first file and rename to the // second file. The second time, we start appending to the second file, // that was created the first time, and rename back to the first file. let firstFile = i == 0 ? destFile1 : destFile2; let secondFile = i == 0 ? destFile2 : destFile1; saver.setTarget(firstFile, false); saver.setTarget(secondFile, false); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. Assert.ok(!firstFile.exists()); let expectedContents = i == 0 ? TEST_DATA_SHORT : TEST_DATA_SHORT + TEST_DATA_SHORT; await promiseVerifyContents(secondFile, expectedContents); } // Clean up. destFile1.remove(false); }); add_task(async function test_enableAppend_hash() { // This test checks append mode, also verifying that the computed hash // includes the contents of the existing data. let destFile = getTempFile(TEST_FILE_NAME_1); // Test the case where the file does not already exists first, then the case // where the file already exists. for (let i = 0; i < 2; i++) { let saver = new BackgroundFileSaverOutputStream(); saver.enableAppend(); saver.enableSha256(); let completionPromise = promiseSaverComplete(saver); saver.setTarget(destFile, false); await promiseCopyToSaver(TEST_DATA_LONG, saver, true); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. let expectedContents = i == 0 ? TEST_DATA_LONG : TEST_DATA_LONG + TEST_DATA_LONG; await promiseVerifyContents(destFile, expectedContents); Assert.equal( EXPECTED_HASHES[expectedContents.length], toHex(saver.sha256Hash) ); } // Clean up. destFile.remove(false); }); add_task(async function test_finish_only() { // This test checks creating the object and doing nothing. let saver = new BackgroundFileSaverOutputStream(); function onTargetChange() { do_throw("Should not receive the onTargetChange notification."); } let completionPromise = promiseSaverComplete(saver, onTargetChange); saver.finish(Cr.NS_OK); await completionPromise; }); add_task(async function test_empty() { // This test checks we still create an empty file when no data is fed. let destFile = getTempFile(TEST_FILE_NAME_1); let saver = new BackgroundFileSaverOutputStream(); let completionPromise = promiseSaverComplete(saver); saver.setTarget(destFile, false); await promiseCopyToSaver("", saver, true); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. Assert.ok(destFile.exists()); Assert.equal(destFile.fileSize, 0); // Clean up. destFile.remove(false); }); add_task(async function test_empty_hash() { // This test checks the hash of an empty file, both in normal and append mode. let destFile = getTempFile(TEST_FILE_NAME_1); // Test normal mode first, then append mode. for (let i = 0; i < 2; i++) { let saver = new BackgroundFileSaverOutputStream(); if (i == 1) { saver.enableAppend(); } saver.enableSha256(); let completionPromise = promiseSaverComplete(saver); saver.setTarget(destFile, false); await promiseCopyToSaver("", saver, true); saver.finish(Cr.NS_OK); await completionPromise; // Verify results. Assert.equal(destFile.fileSize, 0); Assert.equal(EXPECTED_HASHES[0], toHex(saver.sha256Hash)); } // Clean up. destFile.remove(false); }); add_task(async function test_invalid_hash() { let saver = new BackgroundFileSaverStreamListener(); let completionPromise = promiseSaverComplete(saver); // We shouldn't be able to get the hash if hashing hasn't been enabled try { saver.sha256Hash; do_throw("Shouldn't be able to get hash if hashing not enabled"); } catch (ex) { if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } } // Enable hashing, but don't feed any data to saver saver.enableSha256(); let destFile = getTempFile(TEST_FILE_NAME_1); saver.setTarget(destFile, false); // We don't wait on promiseSaverComplete, so the hash getter can run before // or after onSaveComplete is called. However, the expected behavior is the // same in both cases since the hash is only valid when the save completes // successfully. saver.finish(Cr.NS_ERROR_FAILURE); try { saver.sha256Hash; do_throw("Shouldn't be able to get hash if save did not succeed"); } catch (ex) { if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } } // Wait for completion so that the worker thread finishes dealing with the // target file. We expect it to fail. try { await completionPromise; do_throw("completionPromise should throw"); } catch (ex) { if (ex.result != Cr.NS_ERROR_FAILURE) { throw ex; } } }); add_task(async function test_signature() { // Check that we get a signature if the saver is finished. let destFile = getTempFile(TEST_FILE_NAME_1); let saver = new BackgroundFileSaverOutputStream(); let completionPromise = promiseSaverComplete(saver); try { saver.signatureInfo; do_throw("Can't get signature if saver is not complete"); } catch (ex) { if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } } saver.enableSignatureInfo(); saver.setTarget(destFile, false); await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); saver.finish(Cr.NS_OK); await completionPromise; await promiseVerifyContents(destFile, TEST_DATA_SHORT); // signatureInfo is an empty nsIArray Assert.equal(0, saver.signatureInfo.length); // Clean up. destFile.remove(false); }); add_task(async function test_signature_not_enabled() { // Check that we get a signature if the saver is finished on Windows. let destFile = getTempFile(TEST_FILE_NAME_1); let saver = new BackgroundFileSaverOutputStream(); let completionPromise = promiseSaverComplete(saver); saver.setTarget(destFile, false); await promiseCopyToSaver(TEST_DATA_SHORT, saver, true); saver.finish(Cr.NS_OK); await completionPromise; try { saver.signatureInfo; do_throw("Can't get signature if not enabled"); } catch (ex) { if (ex.result != Cr.NS_ERROR_NOT_AVAILABLE) { throw ex; } } // Clean up. destFile.remove(false); }); add_task(function test_teardown() { gStillRunning = false; });