Bug 1656336: Implement Web Extension downloads.download() - JS part r=geckoview-reviewers,robwu,agi
authorowlishDeveloper <bugzeeeeee@gmail.com>
Wed, 23 Dec 2020 01:37:09 +0000
changeset 561671 0d622574cd36ca8e63261e4f03f3e03b4aafcb07
parent 561670 740e282b90001531f30b3715df32c3770febba90
child 561672 f725a528bb4cdee5eb7e7d019fd347a840065134
child 561673 1cdb587387a31ffa02cb0bbd9ea42131a55e848e
push id38053
push userabutkovits@mozilla.com
push dateWed, 23 Dec 2020 09:27:36 +0000
treeherdermozilla-central@f725a528bb4c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgeckoview-reviewers, robwu, agi
bugs1656336
milestone86.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1656336: Implement Web Extension downloads.download() - JS part r=geckoview-reviewers,robwu,agi Differential Revision: https://phabricator.services.mozilla.com/D99572
mobile/android/components/extensions/ext-downloads.js
mobile/android/components/extensions/jar.mn
mobile/android/components/extensions/test/mochitest/mochitest.ini
mobile/android/components/extensions/test/mochitest/test_ext_downloads_saveAs.html
toolkit/components/extensions/test/mochitest/mochitest-common.ini
toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html
new file mode 100644
--- /dev/null
+++ b/mobile/android/components/extensions/ext-downloads.js
@@ -0,0 +1,199 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+  this,
+  "DownloadPaths",
+  "resource://gre/modules/DownloadPaths.jsm"
+);
+
+var { ignoreEvent } = ExtensionCommon;
+
+const REQUEST_DOWNLOAD_MESSAGE = "GeckoView:WebExtension:Download";
+
+const FORBIDDEN_HEADERS = [
+  "ACCEPT-CHARSET",
+  "ACCEPT-ENCODING",
+  "ACCESS-CONTROL-REQUEST-HEADERS",
+  "ACCESS-CONTROL-REQUEST-METHOD",
+  "CONNECTION",
+  "CONTENT-LENGTH",
+  "COOKIE",
+  "COOKIE2",
+  "DATE",
+  "DNT",
+  "EXPECT",
+  "HOST",
+  "KEEP-ALIVE",
+  "ORIGIN",
+  "TE",
+  "TRAILER",
+  "TRANSFER-ENCODING",
+  "UPGRADE",
+  "VIA",
+];
+
+const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i;
+
+const State = {
+  IN_PROGRESS: "in_progress",
+  INTERRUPTED: "interrupted",
+  COMPLETE: "complete",
+};
+
+// TODO Bug 1247794: make id and extension info persistent
+class DownloadItem {
+  constructor(downloadInfo, options, extension) {
+    this.id = downloadInfo.id;
+    this.url = options.url;
+    this.referrer = downloadInfo.referrer || "";
+    this.filename = options.filename || "";
+    this.incognito = options.incognito;
+    this.danger = downloadInfo.danger || "safe"; // todo; not implemented in toolkit either
+    this.mime = downloadInfo.mime || "";
+    this.startTime = Date.now().toString();
+    this.state = State.IN_PROGRESS;
+    this.paused = false;
+    this.canResume = false;
+    this.bytesReceived = 0;
+    this.totalBytes = -1;
+    this.fileSize = -1;
+    this.exists = downloadInfo.exists || false;
+    this.byExtensionId = extension?.id;
+    this.byExtensionName = extension?.name;
+  }
+}
+
+this.downloads = class extends ExtensionAPI {
+  getAPI(context) {
+    const { extension } = context;
+    return {
+      downloads: {
+        download(options) {
+          // the validation checks should be kept in sync with the toolkit implementation
+          const { filename } = options;
+          if (filename != null) {
+            if (!filename.length) {
+              return Promise.reject({ message: "filename must not be empty" });
+            }
+
+            const path = OS.Path.split(filename);
+            if (path.absolute) {
+              return Promise.reject({
+                message: "filename must not be an absolute path",
+              });
+            }
+
+            if (path.components.some(component => component == "..")) {
+              return Promise.reject({
+                message: "filename must not contain back-references (..)",
+              });
+            }
+
+            if (
+              path.components.some(component => {
+                const sanitized = DownloadPaths.sanitize(component, {
+                  compressWhitespaces: false,
+                });
+                return component != sanitized;
+              })
+            ) {
+              return Promise.reject({
+                message: "filename must not contain illegal characters",
+              });
+            }
+          }
+
+          if (options.incognito && !context.privateBrowsingAllowed) {
+            return Promise.reject({
+              message: "Private browsing access not allowed",
+            });
+          }
+
+          if (options.headers) {
+            for (const { name } of options.headers) {
+              if (
+                FORBIDDEN_HEADERS.includes(name.toUpperCase()) ||
+                name.match(FORBIDDEN_PREFIXES)
+              ) {
+                return Promise.reject({
+                  message: "Forbidden request header name",
+                });
+              }
+            }
+          }
+
+          return EventDispatcher.instance
+            .sendRequestForResult({
+              type: REQUEST_DOWNLOAD_MESSAGE,
+              options,
+              extensionId: extension.id,
+            })
+            .then(value => {
+              const downloadItem = new DownloadItem(
+                { id: value },
+                options,
+                extension
+              );
+              return downloadItem.id;
+            });
+        },
+
+        removeFile(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        search(query) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        pause(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        resume(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        cancel(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        showDefaultFolder() {
+          throw new ExtensionError("Not implemented");
+        },
+
+        erase(query) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        open(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        show(downloadId) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        getFileIcon(downloadId, options) {
+          throw new ExtensionError("Not implemented");
+        },
+
+        onChanged: ignoreEvent(context, "downloads.onChanged"),
+
+        onCreated: ignoreEvent(context, "downloads.onCreated"),
+
+        onErased: ignoreEvent(context, "downloads.onErased"),
+
+        onDeterminingFilename: ignoreEvent(
+          context,
+          "downloads.onDeterminingFilename"
+        ),
+      },
+    };
+  }
+};
--- a/mobile/android/components/extensions/jar.mn
+++ b/mobile/android/components/extensions/jar.mn
@@ -5,8 +5,10 @@
 geckoview.jar:
     content/ext-android.js
     content/ext-android.json
     content/ext-browserAction.js
     content/ext-c-android.js
     content/ext-c-tabs.js
     content/ext-pageAction.js
     content/ext-tabs.js
+    content/ext-downloads.js
+% override chrome://extensions/content/parent/ext-downloads.js chrome://geckoview/content/ext-downloads.js
--- a/mobile/android/components/extensions/test/mochitest/mochitest.ini
+++ b/mobile/android/components/extensions/test/mochitest/mochitest.ini
@@ -9,18 +9,16 @@ support-files =
   file_bypass_cache.sjs
   file_dummy.html
   file_iframe_document.html
   file_slowed_document.sjs
   head.js
 tags = webextensions
 
 [test_ext_all_apis.html]
-[test_ext_downloads_saveAs.html]
-skip-if = !is_fennec # times out
 [test_ext_tab_runtimeConnect.html]
 skip-if = !is_fennec # times out; bug 1534640 webextension url
 [test_ext_tabs_create.html]
 [test_ext_tabs_events.html]
 [test_ext_tabs_executeScript.html]
 [test_ext_tabs_executeScript_bad.html]
 [test_ext_tabs_executeScript_good.html]
 [test_ext_tabs_executeScript_no_create.html]
deleted file mode 100644
--- a/mobile/android/components/extensions/test/mochitest/test_ext_downloads_saveAs.html
+++ /dev/null
@@ -1,45 +0,0 @@
-<!DOCTYPE HTML>
-<html>
-<head>
-  <meta charset="utf-8">
-  <title>Downloads Test</title>
-  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
-  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
-  <script type="text/javascript" src="head.js"></script>
-  <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
-</head>
-<body>
-
-<script type="text/javascript">
-"use strict";
-
-// saveAs is not implemented on Fennec but this tests that a call to download() that supplies that option passes anyway.
-add_task(async function testDownload() {
-  const extension = ExtensionTestUtils.loadExtension({
-    manifest: {permissions: ["downloads"]},
-
-    background: async function() {
-      const url = URL.createObjectURL(new Blob(["file content"]));
-      const id = await browser.downloads.download({
-        url,
-        saveAs: true,
-      });
-
-      browser.downloads.onChanged.addListener(delta => {
-        if (delta.id == id && delta.state.current === "complete") {
-          browser.test.notifyPass("downloadPass");
-        }
-      });
-    },
-  });
-
-  await extension.startup();
-
-  await extension.awaitFinish("downloadPass");
-
-  await extension.unload();
-});
-</script>
-
-</body>
-</html>
--- a/toolkit/components/extensions/test/mochitest/mochitest-common.ini
+++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
@@ -108,16 +108,17 @@ skip-if = tsan # Times out on TSan, bug 
 skip-if = os == 'android' || tsan # Times out on TSan intermittently, bug 1615184; not supported on Android yet
 [test_ext_cookies_containers.html]
 [test_ext_cookies_expiry.html]
 [test_ext_cookies_first_party.html]
 [test_ext_cookies_incognito.html]
 skip-if = os == 'android' # Bug 1513544 Android does not support multiple windows.
 [test_ext_cookies_permissions_bad.html]
 [test_ext_cookies_permissions_good.html]
+[test_ext_downloads_download.html]
 [test_ext_embeddedimg_iframe_frameAncestors.html]
 [test_ext_exclude_include_globs.html]
 [test_ext_external_messaging.html]
 [test_ext_generate.html]
 [test_ext_geolocation.html]
 skip-if = os == 'android' # Android support Bug 1336194
 [test_ext_identity.html]
 skip-if = os == 'android' || tsan # unsupported. tsan: bug 1612707
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html
@@ -0,0 +1,90 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Downloads Test</title>
+  <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function background() {
+  const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html";
+
+  browser.test.assertThrows(
+    () => browser.downloads.download(),
+    /Incorrect argument types for downloads.download/,
+    "Should fail without options"
+  );
+
+  browser.test.assertThrows(
+    () => browser.downloads.download({url: "invalid url"}),
+    /invalid url is not a valid URL/,
+    "Should fail on invalid URL"
+  );
+
+  browser.test.assertThrows(
+    () => browser.downloads.download({}),
+    /Property "url" is required/,
+    "Should fail with no URL"
+  );
+
+  browser.test.assertThrows(
+    () => browser.downloads.download({url, method: "DELETE"}),
+    /Invalid enumeration value "DELETE"/,
+    "Should fail with invalid method"
+  );
+
+  await browser.test.assertRejects(
+    browser.downloads.download({url, headers: [{name: "Host", value: "Banana"}]}),
+    /Forbidden request header name/,
+    "Should fail with a forbidden header"
+  );
+
+  await browser.test.assertRejects(
+    browser.downloads.download({url, filename: "/tmp/file.gif"}),
+    /filename must not be an absolute path/,
+    "Should fail with an absolute file path"
+  );
+
+  await browser.test.assertRejects(
+    browser.downloads.download({url, filename: ""}),
+    /filename must not be empty/,
+    "Should fail with an empty file path"
+  );
+
+  await browser.test.assertRejects(
+    browser.downloads.download({url, filename: "file."}),
+    /filename must not contain illegal characters/,
+    "Should fail with a dot in the filename"
+  );
+
+  await browser.test.assertRejects(
+    browser.downloads.download({url, filename: "../file.gif"}),
+    /filename must not contain back-references/,
+    "Should fail with a file path that contains back-references"
+  );
+
+  browser.test.notifyPass("download.done");
+}
+
+add_task(async function test_invalid_download_parameters() {
+  const extension = ExtensionTestUtils.loadExtension({
+    manifest: {permissions: ["downloads"]},
+    background,
+  });
+  await extension.startup();
+
+  await extension.awaitFinish("download.done");
+
+  await extension.unload();
+});
+</script>
+
+</body>
+</html>