From 1c447df70b32d0208a10506d59fdc966cfcd032e Mon Sep 17 00:00:00 2001 From: Sam Foster Date: Fri, 26 Jun 2020 02:34:03 +0000 Subject: [PATCH] Bug 1639069 - Provide helpers for getting a nsIMIMEInfo on DownloadsCommon, and confirming if a download is a given mime-type. r=jaws Differential Revision: https://phabricator.services.mozilla.com/D79395 --- .../components/downloads/DownloadsCommon.jsm | 68 +++++++ browser/components/downloads/moz.build | 2 + .../components/downloads/test/unit/head.js | 87 +++++++++ .../unit/test_DownloadsCommon_getMimeInfo.js | 175 ++++++++++++++++++ .../unit/test_DownloadsCommon_isFileOfType.js | 153 +++++++++++++++ .../downloads/test/unit/xpcshell.ini | 8 + 6 files changed, 493 insertions(+) create mode 100644 browser/components/downloads/test/unit/head.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js create mode 100644 browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js create mode 100644 browser/components/downloads/test/unit/xpcshell.ini diff --git a/browser/components/downloads/DownloadsCommon.jsm b/browser/components/downloads/DownloadsCommon.jsm index d20c8c42d92a..66fd3089e064 100644 --- a/browser/components/downloads/DownloadsCommon.jsm +++ b/browser/components/downloads/DownloadsCommon.jsm @@ -51,6 +51,7 @@ XPCOMUtils.defineLazyServiceGetters(this, { "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper", ], + gMIMEService: ["@mozilla.org/mime;1", "nsIMIMEService"], }); XPCOMUtils.defineLazyGetter(this, "DownloadsLogger", () => { @@ -185,6 +186,12 @@ const kFileExtensions = [ "zip", ]; +const kGenericContentTypes = [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", +]; + const TELEMETRY_EVENT_CATEGORY = "downloads"; var PrefObserver = { @@ -416,6 +423,67 @@ var DownloadsCommon = { await download.finalize(true); }, + /** + * Get a nsIMIMEInfo object for a download + */ + getMimeInfo(download) { + if (!download.succeeded) { + return null; + } + let contentType = download.contentType; + let url = Cc["@mozilla.org/network/standard-url-mutator;1"] + .createInstance(Ci.nsIURIMutator) + .setSpec("http://example.com") // construct the URL + .setFilePath(download.target.path) + .finalize() + .QueryInterface(Ci.nsIURL); + let fileExtension = url.fileExtension; + + // look at file extension if there's no contentType or it is generic + if (!contentType || kGenericContentTypes.includes(contentType)) { + try { + contentType = gMIMEService.getTypeFromExtension(fileExtension); + } catch (ex) { + DownloadsCommon.log( + "Cant get mimeType from file extension: ", + fileExtension + ); + } + } + if (!(contentType || fileExtension)) { + return null; + } + let mimeInfo = null; + try { + mimeInfo = gMIMEService.getFromTypeAndExtension( + contentType || "", + fileExtension || "" + ); + } catch (ex) { + DownloadsCommon.log( + "Can't get nsIMIMEInfo for contentType: ", + contentType, + "and fileExtension:", + fileExtension + ); + } + return mimeInfo; + }, + + /** + * Confirm if the download exists on the filesystem and is a given mime-type + */ + isFileOfType(download, mimeType) { + if (!(download.succeeded && download.target?.exists)) { + DownloadsCommon.log( + `isFileOfType returning false for mimeType: ${mimeType}, succeeded: ${download.succeeded}, exists: ${download.target?.exists}` + ); + return false; + } + let mimeInfo = DownloadsCommon.getMimeInfo(download); + return mimeInfo?.type === mimeType.toLowerCase(); + }, + /** * Copies the source URI of the given Download object to the clipboard. */ diff --git a/browser/components/downloads/moz.build b/browser/components/downloads/moz.build index 534caea6a03f..c0df1b7d29fa 100644 --- a/browser/components/downloads/moz.build +++ b/browser/components/downloads/moz.build @@ -25,3 +25,5 @@ if toolkit == 'cocoa': with Files('**'): BUG_COMPONENT = ('Firefox', 'Downloads Panel') + +XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini'] \ No newline at end of file diff --git a/browser/components/downloads/test/unit/head.js b/browser/components/downloads/test/unit/head.js new file mode 100644 index 000000000000..6e26b8194526 --- /dev/null +++ b/browser/components/downloads/test/unit/head.js @@ -0,0 +1,87 @@ +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "Downloads", + "resource://gre/modules/Downloads.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadsCommon", + "resource:///modules/DownloadsCommon.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "FileTestUtils", + "resource://testing-common/FileTestUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TestUtils", + "resource://testing-common/TestUtils.jsm" +); + +async function createDownloadedFile(pathname, contents) { + info("createDownloadedFile: " + pathname); + let encoder = new TextEncoder(); + let file = new FileUtils.File(pathname); + if (file.exists()) { + info(`File at ${pathname} already exists`); + if (!contents) { + ok( + false, + `A file already exists at ${pathname}, but createDownloadedFile was asked to create a non-existant file` + ); + } + } + if (contents) { + await OS.File.writeAtomic(pathname, encoder.encode(contents)); + ok(file.exists(), `Created ${pathname}`); + } + // No post-test cleanup necessary; tmp downloads directory is already removed after each test + return file; +} + +let gDownloadDir; + +async function setDownloadDir() { + let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tmpDir.append("testsavedir"); + if (!tmpDir.exists()) { + tmpDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + registerCleanupFunction(function() { + try { + tmpDir.remove(true); + } catch (e) { + // On Windows debug build this may fail. + } + }); + } + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setCharPref("browser.download.dir", tmpDir.path); + return tmpDir.path; +} + +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} + +add_task(async function test_common_initialize() { + gDownloadDir = await setDownloadDir(); + Services.prefs.setCharPref("browser.download.loglevel", "Debug"); +}); diff --git a/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js b/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js new file mode 100644 index 000000000000..89d1bf708a75 --- /dev/null +++ b/browser/components/downloads/test/unit/test_DownloadsCommon_getMimeInfo.js @@ -0,0 +1,175 @@ +const DATA_PDF = atob( + "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G" +); + +const DOWNLOAD_TEMPLATE = { + source: { + url: "https://example.com/download", + }, + target: { + path: "", + }, + contentType: "text/plain", + succeeded: DownloadsCommon.DOWNLOAD_FINISHED, + canceled: false, + error: null, + hasPartialData: false, + hasBlockedData: false, + startTime: new Date(Date.now() - 1000), +}; + +const TESTFILES = { + "download-test.txt": "Text file contents\n", + "download-test.pdf": DATA_PDF, + "download-test.PDF": DATA_PDF, + "download-test.xxunknown": "Unknown file contents\n", + "download-test": "No extension file contents\n", +}; +let gPublicList; + +add_task(async function test_setup() { + Assert.ok( + OS.Constants.Path.profileDir, + "profileDir: " + OS.Constants.Path.profileDir + ); + for (let [filename, contents] of Object.entries(TESTFILES)) { + TESTFILES[filename] = await createDownloadedFile( + OS.Path.join(gDownloadDir, filename), + contents + ); + } + gPublicList = await Downloads.getList(Downloads.PUBLIC); +}); + +const TESTCASES = [ + { + name: "Check returned value is null when the download did not succeed", + testFile: "download-test.txt", + contentType: "text/plain", + succeeded: false, + expected: null, + }, + { + name: + "Check correct mime-info is returned when download contentType is unambiguous", + testFile: "download-test.txt", + contentType: "text/plain", + expected: { + type: "text/plain", + }, + }, + { + name: + "Returns correct mime-info from file extension when download contentType is missing", + testFile: "download-test.pdf", + contentType: undefined, + expected: { + type: "application/pdf", + }, + }, + { + name: "Returns correct mime-info from file extension case-insensitively", + testFile: "download-test.PDF", + contentType: undefined, + expected: { + type: "application/pdf", + }, + }, + { + name: + "Returns null when contentType is missing and file extension is unknown", + testFile: "download-test.xxunknown", + contentType: undefined, + expected: null, + }, + { + name: + "Returns contentType when contentType is ambiguous and file extension is unknown", + testFile: "download-test.xxunknown", + contentType: "application/octet-stream", + expected: { + type: "application/octet-stream", + }, + }, + { + name: + "Returns contentType when contentType is ambiguous and there is no file extension", + testFile: "download-test", + contentType: "application/octet-stream", + expected: { + type: "application/octet-stream", + }, + }, + { + name: "Returns null when there's no contentType and no file extension", + testFile: "download-test", + contentType: undefined, + expected: null, + }, +]; + +// add tests for each of the generic mime-types we recognize, +// to ensure they prefer the associated mime-type of the target file extension +for (let type of [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", +]) { + TESTCASES.push({ + name: `Returns correct mime-info from file extension when contentType is generic (${type})`, + testFile: "download-test.pdf", + contentType: type, + expected: { + type: "application/pdf", + }, + }); +} + +for (let testData of TESTCASES) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_getMimeInfo_basic_function(testData); + }, + }; + add_task(tmp[testData.name]); +} + +/** + * Sanity test the DownloadsCommon.getMimeInfo method with test parameters + */ +async function test_getMimeInfo_basic_function(testData) { + let downloadData = { + ...DOWNLOAD_TEMPLATE, + source: "source" in testData ? testData.source : DOWNLOAD_TEMPLATE.source, + succeeded: + "succeeded" in testData + ? testData.succeeded + : DOWNLOAD_TEMPLATE.succeeded, + target: TESTFILES[testData.testFile], + contentType: testData.contentType, + }; + Assert.ok(downloadData.target instanceof Ci.nsIFile, "target is a nsIFile"); + let download = await Downloads.createDownload(downloadData); + await gPublicList.add(download); + await download.refresh(); + + Assert.ok( + await OS.File.exists(download.target.path), + "The file should actually exist." + ); + let result = await DownloadsCommon.getMimeInfo(download); + if (testData.expected) { + Assert.equal( + result.type, + testData.expected.type, + "Got expected mimeInfo.type" + ); + } else { + Assert.equal( + result, + null, + `Expected null, got object with type: ${result?.type}` + ); + } +} diff --git a/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js b/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js new file mode 100644 index 000000000000..e921d7b54068 --- /dev/null +++ b/browser/components/downloads/test/unit/test_DownloadsCommon_isFileOfType.js @@ -0,0 +1,153 @@ +const DATA_PDF = atob( + "JVBERi0xLjANCjEgMCBvYmo8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PmVuZG9iaiAyIDAgb2JqPDwvVHlwZS9QYWdlcy9LaWRzWzMgMCBSXS9Db3VudCAxPj5lbmRvYmogMyAwIG9iajw8L1R5cGUvUGFnZS9NZWRpYUJveFswIDAgMyAzXT4+ZW5kb2JqDQp4cmVmDQowIDQNCjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxMCAwMDAwMCBuDQowMDAwMDAwMDUzIDAwMDAwIG4NCjAwMDAwMDAxMDIgMDAwMDAgbg0KdHJhaWxlcjw8L1NpemUgNC9Sb290IDEgMCBSPj4NCnN0YXJ0eHJlZg0KMTQ5DQolRU9G" +); + +const DOWNLOAD_TEMPLATE = { + source: { + url: "https://download-test.com/download", + }, + target: { + path: "", + }, + contentType: "text/plain", + succeeded: DownloadsCommon.DOWNLOAD_FINISHED, + canceled: false, + error: null, + hasPartialData: false, + hasBlockedData: false, + startTime: new Date(Date.now() - 1000), +}; + +const TESTFILES = { + "download-test.pdf": DATA_PDF, + "download-test.xxunknown": DATA_PDF, + "download-test-missing.pdf": null, +}; +let gPublicList; + +add_task(async function test_setup() { + Assert.ok( + OS.Constants.Path.profileDir, + "profileDir: " + OS.Constants.Path.profileDir + ); + for (let [filename, contents] of Object.entries(TESTFILES)) { + TESTFILES[filename] = await createDownloadedFile( + OS.Path.join(gDownloadDir, filename), + contents + ); + } + gPublicList = await Downloads.getList(Downloads.PUBLIC); +}); + +const TESTCASES = [ + { + name: "Null download arg", + typeArg: "application/pdf", + downloadProps: null, + expected: /TypeError/, + }, + { + name: "Missing type arg", + typeArg: undefined, + downloadProps: { + target: "download-test.pdf", + }, + expected: /TypeError/, + }, + { + name: "Empty string type arg", + typeArg: "", + downloadProps: { + target: "download-test.pdf", + }, + expected: false, + }, + { + name: + "download succeeded, file exists, unknown extension but contentType matches", + typeArg: "application/pdf", + downloadProps: { + target: "download-test.xxunknown", + contentType: "application/pdf", + }, + expected: true, + }, + { + name: + "download succeeded, file exists, contentType is generic and file extension maps to matching mime-type", + typeArg: "application/pdf", + downloadProps: { + target: "download-test.pdf", + contentType: "application/unknown", + }, + expected: true, + }, + { + name: "download did not succeed", + typeArg: "application/pdf", + downloadProps: { + target: "download-test.pdf", + contentType: "application/pdf", + succeeded: false, + }, + expected: false, + }, + { + name: "file does not exist", + typeArg: "application/pdf", + downloadProps: { + target: "download-test-missing.pdf", + contentType: "application/pdf", + }, + expected: false, + }, + { + name: + "contentType is missing and file extension doesnt map to a known mime-type", + typeArg: "application/pdf", + downloadProps: { + contentType: undefined, + target: "download-test.xxunknown", + }, + expected: false, + }, +]; + +for (let testData of TESTCASES) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_isFileOfType(testData); + }, + }; + add_task(tmp[testData.name]); +} + +/** + * Sanity test the DownloadsCommon.isFileOfType method with test parameters + */ +async function test_isFileOfType({ name, typeArg, downloadProps, expected }) { + let download, result; + if (downloadProps) { + let downloadData = { + ...DOWNLOAD_TEMPLATE, + ...downloadProps, + }; + downloadData.target = TESTFILES[downloadData.target]; + Assert.ok(downloadData.target instanceof Ci.nsIFile, "target is a nsIFile"); + download = await Downloads.createDownload(downloadData); + await gPublicList.add(download); + await download.refresh(); + } + + if (typeof expected == "boolean") { + result = await DownloadsCommon.isFileOfType(download, typeArg); + Assert.equal(result, expected, "Expected result from call to isFileOfType"); + } else { + Assert.throws( + () => DownloadsCommon.isFileOfType(download, typeArg), + expected, + "isFileOfType should throw an exception if either the download object or mime-type arguments are falsey" + ); + } +} diff --git a/browser/components/downloads/test/unit/xpcshell.ini b/browser/components/downloads/test/unit/xpcshell.ini new file mode 100644 index 000000000000..103eb98e6d32 --- /dev/null +++ b/browser/components/downloads/test/unit/xpcshell.ini @@ -0,0 +1,8 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +skip-if = toolkit == 'android' + + +[test_DownloadsCommon_getMimeInfo.js] +[test_DownloadsCommon_isFileOfType.js]