Bug 859059 - Implement "Copy as curl". r=msucan, r=vp
☠☠ backed out by 6d0a341040a9 ☠ ☠
authorThomas Andersen <thomas@mr-andersen.no>
Thu, 20 Mar 2014 02:02:08 +0100
changeset 174812 70ed331073017015d471a7a531a7da00cca6fc0f
parent 174811 7b59b92580faad66da1929b9e87cae2718e427aa
child 174813 18f579c4308eab391b110a135678cef3764470f4
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersmsucan, vp
bugs859059
milestone31.0a1
Bug 859059 - Implement "Copy as curl". r=msucan, r=vp
browser/devtools/netmonitor/netmonitor-controller.js
browser/devtools/netmonitor/netmonitor-view.js
browser/devtools/netmonitor/netmonitor.xul
browser/devtools/netmonitor/test/browser.ini
browser/devtools/netmonitor/test/browser_net_copy_as_curl.js
browser/devtools/netmonitor/test/browser_net_curl-utils.js
browser/devtools/netmonitor/test/head.js
browser/devtools/netmonitor/test/html_copy-as-curl.html
browser/devtools/netmonitor/test/html_curl-utils.html
browser/devtools/shared/Curl.jsm
browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
--- a/browser/devtools/netmonitor/netmonitor-controller.js
+++ b/browser/devtools/netmonitor/netmonitor-controller.js
@@ -135,16 +135,19 @@ Object.defineProperty(this, "NetworkHelp
   },
   configurable: true,
   enumerable: true
 });
 
 XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
   "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Curl",
+  "resource:///modules/devtools/Curl.jsm");
+
 /**
  * Object defining the network monitor controller components.
  */
 let NetMonitorController = {
   /**
    * Initializes the view.
    *
    * @return object
--- a/browser/devtools/netmonitor/netmonitor-view.js
+++ b/browser/devtools/netmonitor/netmonitor-view.js
@@ -519,16 +519,47 @@ RequestsMenuView.prototype = Heritage.ex
    * Copy the request url from the currently selected item.
    */
   copyUrl: function() {
     let selected = this.selectedItem.attachment;
     clipboardHelper.copyString(selected.url, document);
   },
 
   /**
+   * Copy a cURL command from the currently selected item.
+   */
+  copyAsCurl: function() {
+    let selected = this.selectedItem.attachment;
+    Task.spawn(function*() {
+      // Create a sanitized object for the Curl command generator.
+      let data = {
+        url: selected.url,
+        method: selected.method,
+        headers: [],
+        httpVersion: selected.httpVersion,
+        postDataText: null
+      };
+
+      // Fetch header values.
+      for (let { name, value } of selected.requestHeaders.headers) {
+        let text = yield gNetwork.getString(value);
+        data.headers.push({ name: name, value: text });
+      }
+
+      // Fetch the request payload.
+      if (selected.requestPostData) {
+        let postData = selected.requestPostData.postData.text;
+        data.postDataText = yield gNetwork.getString(postData);
+      }
+
+      clipboardHelper.copyString(Curl.generateCommand(data), document);
+    });
+  },
+
+  /**
    * Copy image as data uri.
    */
   copyImageAsDataUri: function() {
     let selected = this.selectedItem.attachment;
     let { mimeType, text, encoding } = selected.responseContent.content;
 
     gNetwork.getString(text).then(aString => {
       let data = "data:" + mimeType + ";" + encoding + "," + aString;
@@ -1555,16 +1586,19 @@ RequestsMenuView.prototype = Heritage.ex
 
     let resendElement = $("#request-menu-context-resend");
     resendElement.hidden = !NetMonitorController.supportsCustomRequest ||
                            !selectedItem || selectedItem.attachment.isCustom;
 
     let copyUrlElement = $("#request-menu-context-copy-url");
     copyUrlElement.hidden = !selectedItem;
 
+    let copyAsCurlElement = $("#request-menu-context-copy-as-curl");
+    copyAsCurlElement.hidden = !selectedItem || !selectedItem.attachment.responseContent;
+
     let copyImageAsDataUriElement = $("#request-menu-context-copy-image-as-data-uri");
     copyImageAsDataUriElement.hidden = !selectedItem ||
       !selectedItem.attachment.responseContent ||
       !selectedItem.attachment.responseContent.content.mimeType.contains("image/");
 
     let newTabElement = $("#request-menu-context-newtab");
     newTabElement.hidden = !selectedItem;
   },
--- a/browser/devtools/netmonitor/netmonitor.xul
+++ b/browser/devtools/netmonitor/netmonitor.xul
@@ -24,16 +24,19 @@
   <popupset id="networkPopupSet">
     <menupopup id="network-request-popup">
       <menuitem id="request-menu-context-newtab"
                 label="&netmonitorUI.context.newTab;"
                 accesskey="&netmonitorUI.context.newTab.accesskey;"/>
       <menuitem id="request-menu-context-copy-url"
                 label="&netmonitorUI.context.copyUrl;"
                 accesskey="&netmonitorUI.context.copyUrl.accesskey;"/>
+      <menuitem id="request-menu-context-copy-as-curl"
+                label="&netmonitorUI.context.copyAsCurl;"
+                oncommand="NetMonitorView.RequestsMenu.copyAsCurl();"/>
       <menuitem id="request-menu-context-copy-image-as-data-uri"
                 label="&netmonitorUI.context.copyImageAsDataUri;"
                 accesskey="&netmonitorUI.context.copyImageAsDataUri.accesskey;"/>
       <menuitem id="request-menu-context-resend"
                 label="&netmonitorUI.summary.editAndResend;"
                 accesskey="&netmonitorUI.summary.editAndResend.accesskey;"/>
       <menuseparator/>
       <menuitem id="request-menu-context-perf"
--- a/browser/devtools/netmonitor/test/browser.ini
+++ b/browser/devtools/netmonitor/test/browser.ini
@@ -16,16 +16,18 @@ support-files =
   html_params-test-page.html
   html_post-data-test-page.html
   html_post-raw-test-page.html
   html_post-raw-with-headers-test-page.html
   html_simple-test-page.html
   html_sorting-test-page.html
   html_statistics-test-page.html
   html_status-codes-test-page.html
+  html_copy-as-curl.html
+  html_curl-utils.html
   sjs_content-type-test-server.sjs
   sjs_simple-test-server.sjs
   sjs_sorting-test-server.sjs
   sjs_status-codes-test-server.sjs
   test-image.png
 
 [browser_net_aaa_leaktest.js]
 [browser_net_accessibility-01.js]
@@ -34,18 +36,20 @@ support-files =
 [browser_net_charts-01.js]
 [browser_net_charts-02.js]
 [browser_net_charts-03.js]
 [browser_net_charts-04.js]
 [browser_net_charts-05.js]
 [browser_net_clear.js]
 [browser_net_complex-params.js]
 [browser_net_content-type.js]
+[browser_net_curl-utils.js]
 [browser_net_copy_image_as_data_uri.js]
 [browser_net_copy_url.js]
+[browser_net_copy_as_curl.js]
 [browser_net_cyrillic-01.js]
 [browser_net_cyrillic-02.js]
 [browser_net_filter-01.js]
 [browser_net_filter-02.js]
 [browser_net_filter-03.js]
 [browser_net_footer-summary.js]
 [browser_net_html-preview.js]
 [browser_net_icon-preview.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_copy_as_curl.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if Copy as cURL works.
+ */
+
+function test() {
+  initNetMonitor(CURL_URL).then(([aTab, aDebuggee, aMonitor]) => {
+    info("Starting test... ");
+
+    const EXPECTED_POSIX_RESULT = [
+      "curl",
+      "'" + SIMPLE_SJS + "'",
+      "-H 'Host: example.com'",
+      "-H 'User-Agent: " + aDebuggee.navigator.userAgent + "'",
+      "-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'",
+      "-H 'Accept-Language: " + aDebuggee.navigator.language + "'",
+      "-H 'Accept-Encoding: gzip, deflate'",
+      "-H 'X-Custom-Header-1: Custom value'",
+      "-H 'X-Custom-Header-2: 8.8.8.8'",
+      "-H 'X-Custom-Header-3: Mon, 3 Mar 2014 11:11:11 GMT'",
+      "-H 'Referer: " + CURL_URL + "'",
+      "-H 'Connection: keep-alive'"
+    ].join(" ");
+
+    const EXPECTED_WIN_RESULT = [
+      'curl',
+      '"' + SIMPLE_SJS + '"',
+      '-H "Host: example.com"',
+      '-H "User-Agent: ' + aDebuggee.navigator.userAgent + '"',
+      '-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"',
+      '-H "Accept-Language: ' + aDebuggee.navigator.language + '"',
+      '-H "Accept-Encoding: gzip, deflate"',
+      '-H "X-Custom-Header-1: Custom value"',
+      '-H "X-Custom-Header-2: 8.8.8.8"',
+      '-H "X-Custom-Header-3: Mon, 3 Mar 2014 11:11:11 GMT"',
+      '-H "Referer: ' + CURL_URL + '"',
+      '-H "Connection: keep-alive"'
+    ].join(" ");
+
+    const EXPECTED_RESULT = Services.appinfo.OS == "WINNT" ?
+                            EXPECTED_WIN_RESULT : EXPECTED_POSIX_RESULT;
+
+    let { NetMonitorView } = aMonitor.panelWin;
+    let { RequestsMenu } = NetMonitorView;
+
+    RequestsMenu.lazyUpdate = false;
+
+    waitForNetworkEvents(aMonitor, 1).then(() => {
+      let requestItem = RequestsMenu.getItemAtIndex(0);
+      RequestsMenu.selectedItem = requestItem;
+
+      waitForClipboard(EXPECTED_RESULT, function setup() {
+        RequestsMenu.copyAsCurl();
+      }, function onSuccess() {
+        ok(true, "Clipboard contains a cURL command for the currently selected item's url.");
+        cleanUp();
+      }, function onFailure() {
+        ok(false, "Creating a cURL command for the currently selected item was unsuccessful.");
+        cleanUp();
+      });
+
+    });
+
+    aDebuggee.performRequest(SIMPLE_SJS);
+
+    function cleanUp(){
+      teardown(aMonitor).then(finish);
+    }
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_curl-utils.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests Curl Utils functionality.
+ */
+
+function test() {
+  initNetMonitor(CURL_UTILS_URL).then(([aTab, aDebuggee, aMonitor]) => {
+    info("Starting test... ");
+
+    let { NetMonitorView, gNetwork } = aMonitor.panelWin;
+    let { RequestsMenu } = NetMonitorView;
+
+    RequestsMenu.lazyUpdate = false;
+
+    waitForNetworkEvents(aMonitor, 1, 3).then(() => {
+      let requests = {
+        get: RequestsMenu.getItemAtIndex(0),
+        post: RequestsMenu.getItemAtIndex(1),
+        multipart: RequestsMenu.getItemAtIndex(2),
+        multipartForm: RequestsMenu.getItemAtIndex(3)
+      };
+
+      Task.spawn(function*() {
+        yield createCurlData(requests.get.attachment, gNetwork).then((aData) => {
+          test_findHeader(aData);
+        });
+
+        yield createCurlData(requests.post.attachment, gNetwork).then((aData) => {
+          test_isUrlEncodedRequest(aData);
+          test_writePostDataTextParams(aData);
+        });
+
+        yield createCurlData(requests.multipart.attachment, gNetwork).then((aData) => {
+          test_isMultipartRequest(aData);
+          test_getMultipartBoundary(aData);
+          test_removeBinaryDataFromMultipartText(aData);
+        });
+
+        yield createCurlData(requests.multipartForm.attachment, gNetwork).then((aData) => {
+          test_getHeadersFromMultipartText(aData);
+        });
+
+        if (Services.appinfo.OS != "WINNT") {
+          test_escapeStringPosix();
+        } else {
+          test_escapeStringWin();
+        }
+
+        teardown(aMonitor).then(finish);
+      });
+    });
+
+    aDebuggee.performRequests(SIMPLE_SJS);
+  });
+}
+
+function test_isUrlEncodedRequest(aData) {
+  let isUrlEncoded = CurlUtils.isUrlEncodedRequest(aData);
+  ok(isUrlEncoded, "Should return true for url encoded requests.");
+}
+
+function test_isMultipartRequest(aData) {
+  let isMultipart = CurlUtils.isMultipartRequest(aData);
+  ok(isMultipart, "Should return true for multipart/form-data requests.");
+}
+
+function test_findHeader(aData) {
+  let headers = aData.headers;
+  let hostName = CurlUtils.findHeader(headers, "Host");
+  let requestedWithLowerCased = CurlUtils.findHeader(headers, "x-requested-with");
+  let doesNotExist = CurlUtils.findHeader(headers, "X-Does-Not-Exist");
+
+  is(hostName, "example.com",
+    "Header with name 'Host' should be found in the request array.");
+  is(requestedWithLowerCased, "XMLHttpRequest",
+    "The search should be case insensitive.");
+  is(doesNotExist, null,
+    "Should return null when a header is not found.");
+}
+
+function test_writePostDataTextParams(aData) {
+  let params = CurlUtils.writePostDataTextParams(aData.postDataText);
+  is(params, "param1=value1&param2=value2&param3=value3",
+    "Should return a serialized representation of the request parameters");
+}
+
+function test_getMultipartBoundary(aData) {
+  let boundary = CurlUtils.getMultipartBoundary(aData);
+  ok(/-{3,}\w+/.test(boundary),
+    "A boundary string should be found in a multipart request.");
+}
+
+function test_removeBinaryDataFromMultipartText(aData) {
+  let generatedBoundary = CurlUtils.getMultipartBoundary(aData);
+  let text = aData.postDataText;
+  let binaryRemoved =
+    CurlUtils.removeBinaryDataFromMultipartText(text, generatedBoundary);
+  let boundary = "--" + generatedBoundary;
+
+  const EXPECTED_POSIX_RESULT = [
+    "$'",
+    boundary,
+    "\\r\\n\\r\\n",
+    "Content-Disposition: form-data; name=\"param1\"",
+    "\\r\\n\\r\\n",
+    "value1",
+    "\\r\\n",
+    boundary,
+    "\\r\\n\\r\\n",
+    "Content-Disposition: form-data; name=\"file\"; filename=\"filename.png\"",
+    "\\r\\n",
+    "Content-Type: image/png",
+    "\\r\\n\\r\\n",
+    generatedBoundary,
+    "--\\r\\n",
+    "'"
+  ].join("");
+
+  const EXPECTED_WIN_RESULT = [
+    '"' + boundary + '"^',
+    '\u000d\u000A\u000d\u000A',
+    '"Content-Disposition: form-data; name=""param1"""^',
+    '\u000d\u000A\u000d\u000A',
+    '"value1"^',
+    '\u000d\u000A',
+    '"' + boundary + '"^',
+    '\u000d\u000A\u000d\u000A',
+    '"Content-Disposition: form-data; name=""file""; filename=""filename.png"""^',
+    '\u000d\u000A',
+    '"Content-Type: image/png"^',
+    '\u000d\u000A\u000d\u000A',
+    '"' + generatedBoundary + '--"^',
+    '\u000d\u000A',
+    '""'
+  ].join("");
+
+  if (Services.appinfo.OS != "WINNT") {
+    is(CurlUtils.escapeStringPosix(binaryRemoved), EXPECTED_POSIX_RESULT,
+      "The mulitpart request payload should not contain binary data.");
+  } else {
+    is(CurlUtils.escapeStringWin(binaryRemoved), EXPECTED_WIN_RESULT,
+      "WinNT: The mulitpart request payload should not contain binary data.");
+  }
+}
+
+function test_getHeadersFromMultipartText(aData) {
+  let headers = CurlUtils.getHeadersFromMultipartText(aData.postDataText);
+
+  ok(Array.isArray(headers),
+    "Should return an array.");
+  ok(headers.length > 0,
+    "There should exist at least one request header.");
+  is(headers[0].name, "Content-Type",
+    "The first header name should be 'Content-Type'.");
+}
+
+function test_escapeStringPosix() {
+  let surroundedWithQuotes = "A simple string";
+  is(CurlUtils.escapeStringPosix(surroundedWithQuotes), "'A simple string'",
+    "The string should be surrounded with single quotes.");
+
+  let singleQuotes = "It's unusual to put crickets in your coffee.";
+  is(CurlUtils.escapeStringPosix(singleQuotes),
+    "$'It\\'s unusual to put crickets in your coffee.'",
+    "Single quotes should be escaped.");
+
+  let newLines = "Line 1\r\nLine 2\u000d\u000ALine3";
+  is(CurlUtils.escapeStringPosix(newLines), "$'Line 1\\r\\nLine 2\\r\\nLine3'",
+    "Newlines should be escaped.");
+
+  let controlChars = "\u0007 \u0009 \u000C \u001B";
+  is(CurlUtils.escapeStringPosix(controlChars), "$'\\x07 \\x09 \\x0c \\x1b'",
+    "Control characters should be escaped.");
+
+  let extendedAsciiChars = "æ ø ü ß ö é";
+  is(CurlUtils.escapeStringPosix(extendedAsciiChars),
+    "$'\\xc3\\xa6 \\xc3\\xb8 \\xc3\\xbc \\xc3\\x9f \\xc3\\xb6 \\xc3\\xa9'",
+    "Character codes outside of the decimal range 32 - 126 should be escaped.");
+}
+
+function test_escapeStringWin() {
+  let surroundedWithDoubleQuotes = "A simple string";
+  is(CurlUtils.escapeStringWin(surroundedWithDoubleQuotes), '"A simple string"',
+    "The string should be surrounded with double quotes.");
+
+  let doubleQuotes = "Quote: \"Time is an illusion. Lunchtime doubly so.\"";
+  is(CurlUtils.escapeStringWin(doubleQuotes),
+    '"Quote: ""Time is an illusion. Lunchtime doubly so."""',
+    "Double quotes should be escaped.");
+
+  let percentSigns = "%AppData%";
+  is(CurlUtils.escapeStringWin(percentSigns), '""%"AppData"%""',
+    "Percent signs should be escaped.");
+
+  let backslashes = "\\A simple string\\";
+  is(CurlUtils.escapeStringWin(backslashes), '"\\\\A simple string\\\\"',
+    "Backslashes should be escaped.");
+
+  let newLines = "line1\r\nline2\r\nline3";
+  is(CurlUtils.escapeStringWin(newLines),
+    '"line1"^\u000d\u000A"line2"^\u000d\u000A"line3"',
+    "Newlines should be escaped.");
+}
+
+function createCurlData(aSelected, aNetwork) {
+  return Task.spawn(function*() {
+    // Create a sanitized object for the Curl command generator.
+    let data = {
+      url: aSelected.url,
+      method: aSelected.method,
+      headers: [],
+      httpVersion: aSelected.httpVersion,
+      postDataText: null
+    };
+
+    // Fetch header values.
+    for (let { name, value } of aSelected.requestHeaders.headers) {
+      let text = yield aNetwork.getString(value);
+      data.headers.push({ name: name, value: text });
+    }
+
+    // Fetch the request payload.
+    if (aSelected.requestPostData) {
+      let postData = aSelected.requestPostData.postData.text;
+      data.postDataText = yield aNetwork.getString(postData);
+    }
+
+    return data;
+  });
+}
\ No newline at end of file
--- a/browser/devtools/netmonitor/test/head.js
+++ b/browser/devtools/netmonitor/test/head.js
@@ -4,16 +4,17 @@
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 let { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
 let { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
 let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
 let { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let { CurlUtils } = Cu.import("resource:///modules/devtools/Curl.jsm", {});
 let TargetFactory = devtools.TargetFactory;
 let Toolbox = devtools.Toolbox;
 
 const EXAMPLE_URL = "http://example.com/browser/browser/devtools/netmonitor/test/";
 
 const SIMPLE_URL = EXAMPLE_URL + "html_simple-test-page.html";
 const NAVIGATE_URL = EXAMPLE_URL + "html_navigate-test-page.html";
 const CONTENT_TYPE_URL = EXAMPLE_URL + "html_content-type-test-page.html";
@@ -29,16 +30,18 @@ const JSON_LONG_URL = EXAMPLE_URL + "htm
 const JSON_MALFORMED_URL = EXAMPLE_URL + "html_json-malformed-test-page.html";
 const JSON_CUSTOM_MIME_URL = EXAMPLE_URL + "html_json-custom-mime-test-page.html";
 const JSON_TEXT_MIME_URL = EXAMPLE_URL + "html_json-text-mime-test-page.html";
 const SORTING_URL = EXAMPLE_URL + "html_sorting-test-page.html";
 const FILTERING_URL = EXAMPLE_URL + "html_filter-test-page.html";
 const INFINITE_GET_URL = EXAMPLE_URL + "html_infinite-get-page.html";
 const CUSTOM_GET_URL = EXAMPLE_URL + "html_custom-get-page.html";
 const STATISTICS_URL = EXAMPLE_URL + "html_statistics-test-page.html";
+const CURL_URL = EXAMPLE_URL + "html_copy-as-curl.html";
+const CURL_UTILS_URL = EXAMPLE_URL + "html_curl-utils.html";
 
 const SIMPLE_SJS = EXAMPLE_URL + "sjs_simple-test-server.sjs";
 const CONTENT_TYPE_SJS = EXAMPLE_URL + "sjs_content-type-test-server.sjs";
 const STATUS_CODES_SJS = EXAMPLE_URL + "sjs_status-codes-test-server.sjs";
 const SORTING_SJS = EXAMPLE_URL + "sjs_sorting-test-server.sjs";
 
 const TEST_IMAGE = EXAMPLE_URL + "test-image.png";
 const TEST_IMAGE_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==";
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/test/html_copy-as-curl.html
@@ -0,0 +1,27 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Network Monitor test page</title>
+  </head>
+
+  <body>
+    <p>Performing a GET request</p>
+
+    <script type="text/javascript">
+      function performRequest(aUrl) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("GET", aUrl, true);
+        xhr.setRequestHeader("Accept-Language", window.navigator.language);
+        xhr.setRequestHeader("X-Custom-Header-1", "Custom value");
+        xhr.setRequestHeader("X-Custom-Header-2", "8.8.8.8");
+        xhr.setRequestHeader("X-Custom-Header-3", "Mon, 3 Mar 2014 11:11:11 GMT");
+        xhr.send(null);
+      }
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/netmonitor/test/html_curl-utils.html
@@ -0,0 +1,99 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>Network Monitor test page</title>
+  </head>
+
+  <body>
+    <p>Performing requests</p>
+
+    <p>
+      <canvas width="100" height="100"></canvas>
+    </p>
+
+    <hr/>
+
+    <form method="post" action="#" enctype="multipart/form-data" target="target" id="post-form">
+      <input type="text" name="param1" value="value1"/>
+      <input type="text" name="param2" value="value2"/>
+      <input type="text" name="param3" value="value3"/>
+      <input type="submit"/>
+    </form>
+    <iframe name="target"></iframe>
+
+    <script type="text/javascript">
+
+      function ajaxGet(aUrl, aCallback) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("GET", aUrl + "?param1=value1&param2=value2&param3=value3", true);
+        xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+        xhr.onload = function() {
+          aCallback();
+        };
+        xhr.send();
+      }
+
+      function ajaxPost(aUrl, aCallback) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("POST", aUrl, true);
+        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
+        xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+        xhr.onload = function() {
+          aCallback();
+        };
+        var params = "param1=value1&param2=value2&param3=value3";
+        xhr.send(params);
+      }
+
+      function ajaxMultipart(aUrl, aCallback) {
+        var xhr = new XMLHttpRequest();
+        xhr.open("POST", aUrl, true);
+        xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
+        xhr.onload = function() {
+          aCallback();
+        };
+
+        getCanvasElem().toBlob((blob) => {
+          var formData = new FormData();
+          formData.append("param1", "value1");
+          formData.append("file", blob, "filename.png");
+          xhr.send(formData);
+        });
+      }
+
+      function submitForm() {
+        var form = document.querySelector("#post-form");
+        form.submit();
+      }
+
+      function getCanvasElem() {
+        return document.querySelector("canvas");
+      }
+
+      function initCanvas() {
+        var canvas = getCanvasElem();
+        var ctx = canvas.getContext("2d");
+        ctx.fillRect(0,0,100,100);
+        ctx.clearRect(20,20,60,60);
+        ctx.strokeRect(25,25,50,50);
+      }
+
+      function performRequests(aUrl) {
+        ajaxGet(aUrl, () => {
+          ajaxPost(aUrl, () => {
+            ajaxMultipart(aUrl, () => {
+              submitForm();
+            });
+          });
+        });
+      }
+
+      initCanvas();
+    </script>
+  </body>
+
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/Curl.jsm
@@ -0,0 +1,396 @@
+/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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/. */
+
+/*
+ * Copyright (C) 2007, 2008 Apple Inc.  All rights reserved.
+ * Copyright (C) 2008, 2009 Anthony Ricaud <rik@webkit.org>
+ * Copyright (C) 2011 Google Inc. All rights reserved.
+ * Copyright (C) 2009 Mozilla Foundation. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * 1.  Redistributions of source code must retain the above copyright
+ *     notice, this list of conditions and the following disclaimer.
+ * 2.  Redistributions in binary form must reproduce the above copyright
+ *     notice, this list of conditions and the following disclaimer in the
+ *     documentation and/or other materials provided with the distribution.
+ * 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
+ *     its contributors may be used to endorse or promote products derived
+ *     from this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+ * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["Curl", "CurlUtils"];
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+const DEFAULT_HTTP_VERSION = "HTTP/1.1";
+
+this.Curl = {
+  /**
+   * Generates a cURL command string which can be used from the command line etc.
+   *
+   * @param object aData
+   *        Datasource to create the command from.
+   *        The object must contain the following properties:
+   *          - url:string, the URL of the request.
+   *          - method:string, the request method upper cased. HEAD / GET / POST etc.
+   *          - headers:array, an array of request headers {name:x, value:x} tuples.
+   *          - httpVersion:string, http protocol version rfc2616 formatted. Eg. "HTTP/1.1"
+   *          - postDataText:string, optional - the request payload.
+   *
+   * @return string
+   *         A cURL command.
+   */
+  generateCommand: function(aData) {
+    let utils = CurlUtils;
+
+    let command = ["curl"];
+    let ignoredHeaders = new Set();
+
+    // The cURL command is expected to run on the same platform that Firefox runs
+    // (it may be different from the inspected page platform).
+    let escapeString = Services.appinfo.OS == "WINNT" ?
+                       utils.escapeStringWin : utils.escapeStringPosix;
+
+    // Add URL.
+    command.push(escapeString(aData.url));
+
+    let postDataText = null;
+    let multipartRequest = utils.isMultipartRequest(aData);
+
+    // Create post data.
+    let data = [];
+    if (utils.isUrlEncodedRequest(aData) || aData.method == "PUT") {
+      postDataText = aData.postDataText;
+      data.push("--data");
+      data.push(escapeString(utils.writePostDataTextParams(postDataText)));
+      ignoredHeaders.add("Content-Length");
+    } else if (multipartRequest) {
+      postDataText = aData.postDataText;
+      data.push("--data-binary");
+      let boundary = utils.getMultipartBoundary(aData);
+      let text = utils.removeBinaryDataFromMultipartText(postDataText, boundary);
+      data.push(escapeString(text));
+      ignoredHeaders.add("Content-Length");
+    }
+
+    // Add method.
+    // For GET and POST requests this is not necessary as GET is the
+    // default. If --data or --binary is added POST is the default.
+    if (!(aData.method == "GET" || aData.method == "POST")) {
+      command.push("-X");
+      command.push(aData.method);
+    }
+
+    // Add -I (HEAD)
+    // For servers that supports HEAD.
+    // This will fetch the header of a document only.
+    if (aData.method == "HEAD") {
+      command.push("-I");
+    }
+
+    // Add http version.
+    if (aData.httpVersion && aData.httpVersion != DEFAULT_HTTP_VERSION) {
+      command.push("--" + aData.httpVersion.split("/")[1]);
+    }
+
+    // Add request headers.
+    let headers = aData.headers;
+    if (multipartRequest) {
+      let multipartHeaders = utils.getHeadersFromMultipartText(postDataText);
+      headers = headers.concat(multipartHeaders);
+    }
+    for (let i = 0; i < headers.length; i++) {
+      let header = headers[i];
+      if (ignoredHeaders.has(header.name)) {
+        continue;
+      }
+      command.push("-H");
+      command.push(escapeString(header.name + ": " + header.value));
+    }
+
+    // Add post data.
+    command = command.concat(data);
+
+    return command.join(" ");
+  }
+};
+
+/**
+ * Utility functions for the Curl command generator.
+ */
+this.CurlUtils = {
+  /**
+   * Check if the request is an URL encoded request.
+   *
+   * @param object aData
+   *        The data source. See the description in the Curl object.
+   * @return boolean
+   *         True if the request is URL encoded, false otherwise.
+   */
+  isUrlEncodedRequest: function(aData) {
+    let postDataText = aData.postDataText;
+    if (!postDataText) {
+      return false;
+    }
+
+    postDataText = postDataText.toLowerCase();
+    if (postDataText.contains("content-type: application/x-www-form-urlencoded")) {
+      return true;
+    }
+
+    let contentType = this.findHeader(aData.headers, "content-type");
+
+    return (contentType &&
+      contentType.toLowerCase().contains("application/x-www-form-urlencoded"));
+  },
+
+  /**
+   * Check if the request is a multipart request.
+   *
+   * @param object aData
+   *        The data source.
+   * @return boolean
+   *         True if the request is multipart reqeust, false otherwise.
+   */
+  isMultipartRequest: function(aData) {
+    let postDataText = aData.postDataText;
+    if (!postDataText) {
+      return false;
+    }
+
+    postDataText = postDataText.toLowerCase();
+    if (postDataText.contains("content-type: multipart/form-data")) {
+      return true;
+    }
+
+    let contentType = this.findHeader(aData.headers, "content-type");
+
+    return (contentType && 
+      contentType.toLowerCase().contains("multipart/form-data;"));
+  },
+
+  /**
+   * Write out paramters from post data text.
+   *
+   * @param object aPostDataText
+   *        Post data text.
+   * @return string
+   *         Post data parameters.
+   */
+  writePostDataTextParams: function(aPostDataText) {
+    let lines = aPostDataText.split("\r\n");
+    return lines[lines.length - 1];
+  },
+
+  /**
+   * Finds the header with the given name in the headers array.
+   *
+   * @param array aHeaders
+   *        Array of headers info {name:x, value:x}.
+   * @param string aName
+   *        The header name to find.
+   * @return string
+   *         The found header value or null if not found.
+   */
+  findHeader: function(aHeaders, aName) {
+    if (!aHeaders) {
+      return null;
+    }
+
+    let name = aName.toLowerCase();
+    for (let header of aHeaders) {
+      if (name == header.name.toLowerCase()) {
+        return header.value;
+      }
+    }
+
+    return null;
+  },
+
+  /**
+   * Returns the boundary string for a multipart request.
+   * 
+   * @param string aData
+   *        The data source. See the description in the Curl object.
+   * @return string
+   *         The boundary string for the request.
+   */
+  getMultipartBoundary: function(aData) {
+    let boundaryRe = /\bboundary=(-{3,}\w+)/i;
+
+    // Get the boundary string from the Content-Type request header.
+    let contentType = this.findHeader(aData.headers, "Content-Type");
+    if (boundaryRe.test(contentType)) {
+      return contentType.match(boundaryRe)[1];
+    }
+    // Temporary workaround. As of 2014-03-11 the requestHeaders array does not
+    // always contain the Content-Type header for mulitpart requests. See bug 978144. 
+    // Find the header from the request payload.
+    let boundaryString = aData.postDataText.match(boundaryRe)[1];
+    if (boundaryString) {
+      return boundaryString;
+    }
+
+    return null;
+  },
+
+  /**
+   * Removes the binary data from mulitpart text.
+   *
+   * @param string aMultipartText
+   *        Multipart form data text.
+   * @param string aBoundary
+   *        The boundary string.
+   * @return string
+   *         The mulitpart text without the binary data.
+   */
+  removeBinaryDataFromMultipartText: function(aMultipartText, aBoundary) {
+    let result = "";
+    let boundary = "--" + aBoundary;
+    let parts = aMultipartText.split(boundary);
+    for (let part of parts) {
+      // Each part is expected to have a content disposition line.
+      let contentDispositionLine = part.trimLeft().split("\r\n")[0];
+      if (!contentDispositionLine) {
+        continue;
+      }
+      contentDispositionLine = contentDispositionLine.toLowerCase();
+      if (contentDispositionLine.contains("content-disposition: form-data")) {
+        if (contentDispositionLine.contains("filename=")) {
+          // The header lines and the binary blob is separated by 2 CRLF's.
+          // Add only the headers to the result.
+          let headers = part.split("\r\n\r\n")[0];
+          result += boundary + "\r\n" + headers + "\r\n\r\n";
+        }
+        else {
+          result += boundary + "\r\n" + part;
+        }
+      }
+    }
+    result += aBoundary + "--\r\n";
+
+    return result;
+  },
+
+  /**
+   * Get the headers from a multipart post data text.
+   *
+   * @param string aMultipartText
+   *        Multipart post text.
+   * @return array
+   *         An array of header objects {name:x, value:x}
+   */
+  getHeadersFromMultipartText: function(aMultipartText) {
+    let headers = [];
+    if (!aMultipartText || aMultipartText.startsWith("---")) {
+      return headers;
+    }
+
+    // Get the header section.
+    let index = aMultipartText.indexOf("\r\n\r\n");
+    if (index == -1) {
+      return headers;
+    }
+
+    // Parse the header lines.
+    let headersText = aMultipartText.substring(0, index);
+    let headerLines = headersText.split("\r\n");
+    let lastHeaderName = null;
+
+    for (let line of headerLines) {
+      // Create a header for each line in fields that spans across multiple lines.
+      // Subsquent lines always begins with at least one space or tab character.
+      // (rfc2616)
+      if (lastHeaderName && /^\s+/.test(line)) {
+        headers.push({ name: lastHeaderName, value: line.trim() });
+        continue;
+      }
+
+      let indexOfColon = line.indexOf(":");
+      if (indexOfColon == -1) {
+        continue;
+      }
+
+      let header = [line.slice(0, indexOfColon), line.slice(indexOfColon + 1)];
+      if (header.length != 2) {
+        continue;
+      }
+      lastHeaderName = header[0].trim();
+      headers.push({ name: lastHeaderName, value: header[1].trim() });
+    }
+
+    return headers;
+  },
+
+  /**
+   * Escape util function for POSIX oriented operating systems.
+   * Credit: Google DevTools
+   */
+  escapeStringPosix: function(str) {
+    function escapeCharacter(x) {
+      let code = x.charCodeAt(0);
+      if (code < 256) {
+        // Add leading zero when needed to not care about the next character.
+        return code < 16 ? "\\x0" + code.toString(16) : "\\x" + code.toString(16);
+      }
+      code = code.toString(16);
+      return "\\u" + ("0000" + code).substr(code.length, 4);
+    }
+
+    if (/[^\x20-\x7E]|\'/.test(str)) {
+      // Use ANSI-C quoting syntax.
+      return "$\'" + str.replace(/\\/g, "\\\\")
+                        .replace(/\'/g, "\\\'")
+                        .replace(/\n/g, "\\n")
+                        .replace(/\r/g, "\\r")
+                        .replace(/[^\x20-\x7E]/g, escapeCharacter) + "'";
+    } else {
+      // Use single quote syntax.
+      return "'" + str + "'";
+    }
+  },
+
+  /**
+   * Escape util function for Windows systems.
+   * Credit: Google DevTools
+   */
+  escapeStringWin: function(str) {
+    /* Replace quote by double quote (but not by \") because it is
+       recognized by both cmd.exe and MS Crt arguments parser.
+
+       Replace % by "%" because it could be expanded to an environment
+       variable value. So %% becomes "%""%". Even if an env variable ""
+       (2 doublequotes) is declared, the cmd.exe will not
+       substitute it with its value.
+
+       Replace each backslash with double backslash to make sure
+       MS Crt arguments parser won't collapse them.
+
+       Replace new line outside of quotes since cmd.exe doesn't let
+       to do it inside.
+    */
+    return "\"" + str.replace(/"/g, "\"\"")
+                     .replace(/%/g, "\"%\"")
+                     .replace(/\\/g, "\\\\")
+                     .replace(/[\r\n]+/g, "\"^$&\"") + "\"";
+  }
+};
\ No newline at end of file
--- a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
@@ -197,16 +197,22 @@
 <!-- LOCALIZATION NOTE (netmonitorUI.context.perfTools.accesskey): This is the access key
   -  for the performance analysis menu item displayed in the context menu for a request -->
 <!ENTITY netmonitorUI.context.perfTools.accesskey "S">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.context.copyUrl): This is the label displayed
   -  on the context menu that copies the selected request's url -->
 <!ENTITY netmonitorUI.context.copyUrl     "Copy URL">
 
+<!-- LOCALIZATION NOTE (netmonitorUI.context.copyAsCurl): This is the label displayed
+  -  on the context menu that copies the selected request as a cURL command.
+  -  The capitalization is part of the official name and should be used throughout all languages.
+  -  http://en.wikipedia.org/wiki/CURL -->
+<!ENTITY netmonitorUI.context.copyAsCurl    "Copy as cURL">
+
 <!-- LOCALIZATION NOTE (netmonitorUI.context.copyUrl.accesskey): This is the access key
   -  for the Copy URL menu item displayed in the context menu for a request -->
 <!ENTITY netmonitorUI.context.copyUrl.accesskey "C">
 
 <!-- LOCALIZATION NOTE (netmonitorUI.context.copyImageAsDataUri): This is the label displayed
   -  on the context menu that copies the selected image as data uri -->
 <!ENTITY netmonitorUI.context.copyImageAsDataUri "Copy Image as Data URI">