/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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.
 * Copyright (C) 2022, 2023, 2025 Moonchild Productions. 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";

const Services = require("Services");

const DEFAULT_HTTP_VERSION = "HTTP/1.1";

const Curl = {
  /**
   * Generates a cURL command string which can be used from the command line etc.
   *
   * @param object data
   *        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 (data) {
    const utils = CurlUtils;

    let commandParts = [];

    // Make sure to use the following helpers to sanitize arguments before execution.
    const escapeStringifNeeded = value => {
      return /^[a-zA-Z-]+$/.test(value) ? value : escapeString(value);
    };

    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.
    commandParts.push(data.url);

    // Disable globbing if the URL contains brackets.
    // cURL also globs braces but they are already percent-encoded.
    if (data.url.includes("[") || data.url.includes("]")) {
      commandParts.push("--globoff");
    }

    let postDataText = null;
    let multipartRequest = utils.isMultipartRequest(data);

    // Create post data.
    let postData = [];
    if (utils.isUrlEncodedRequest(data) ||
          ["PUT", "POST", "PATCH"].includes(data.method)) {
      postDataText = data.postDataText;
      postData.push("--data-raw");
      let text =utils.writePostDataTextParams(postDataText);
      postData.push(escapeStringifNeeded(text));
      ignoredHeaders.add("content-length");
    } else if (multipartRequest) {
      postDataText = data.postDataText;
      postData.push("--data-binary");
      let boundary = utils.getMultipartBoundary(data);
      let text = utils.removeBinaryDataFromMultipartText(postDataText, boundary);
      postData.push(escapeStringifNeeded(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 (!(data.method == "GET" || data.method == "POST")) {
      commandParts.push("-X");
      commandParts.push(data.method);
    }

    // Add -I (HEAD)
    // For servers that supports HEAD.
    // This will fetch the header of a document only.
    if (data.method == "HEAD") {
      commandParts.push("-I");
    }

    // Add http version.
    if (data.httpVersion && data.httpVersion != DEFAULT_HTTP_VERSION) {
      let version = data.httpVersion.split("/")[1];
      // curl accepts --http1.0, --http1.1 and --http2 for HTTP/1.0, HTTP/1.1
      // and HTTP/2 protocols respectively. But the corresponding values in 
      // data.httpVersion are HTTP/1.0, HTTP/1.1 and HTTP/2.0
      // So in case of HTTP/2.0 (which should ideally be HTTP/2) we are using
      // only major version, and full version in other cases
      commandParts.push("--http" + (version == "2.0" ? version.split(".")[0] : version));
    }

    // Add request headers.
    let headers = data.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 (header.name.toLowerCase() === "accept-encoding") {
        // Ignore transfer encoding (compression) as not all commonly installed
        // versions of curl support this.
        continue;
      }
      if (ignoredHeaders.has(header.name.toLowerCase())) {
        continue;
      }
      commandParts.push("-H");
      let text = header.name + ": " + header.value;
      commandParts.push(escapeStringifNeeded(text));
    }

    // Add post data.
    commandParts = commandParts.concat(postData);

    // Format with line breaks if the command has more than 2 parts
    // e.g
    // Command with 2 parts  - curl https://foo.com
    // Commands with more than 2 parts -
    // curl https://foo.com
    // -X POST
    // -H "Accept : */*"
    // -H "accept-language: en-US"
    const joinStr = Services.appinfo.OS == "WINNT" ? " ^\n  " : " \\\n  ";
    return (
      "curl " + commandParts.join(commandParts.length >= 3 ? joinStr : " ")
    );
  }
};

exports.Curl = Curl;

/**
 * Utility functions for the Curl command generator.
 */
const CurlUtils = {
  /**
   * Check if the request is an URL encoded request.
   *
   * @param object data
   *        The data source. See the description in the Curl object.
   * @return boolean
   *         True if the request is URL encoded, false otherwise.
   */
  isUrlEncodedRequest: function (data) {
    let postDataText = data.postDataText;
    if (!postDataText) {
      return false;
    }

    postDataText = postDataText.toLowerCase();
    if (postDataText.includes("content-type: application/x-www-form-urlencoded")) {
      return true;
    }

    let contentType = this.findHeader(data.headers, "content-type");

    return (contentType &&
      contentType.toLowerCase().includes("application/x-www-form-urlencoded"));
  },

  /**
   * Check if the request is a multipart request.
   *
   * @param object data
   *        The data source.
   * @return boolean
   *         True if the request is multipart reqeust, false otherwise.
   */
  isMultipartRequest: function (data) {
    let postDataText = data.postDataText;
    if (!postDataText) {
      return false;
    }

    postDataText = postDataText.toLowerCase();
    if (postDataText.includes("content-type: multipart/form-data")) {
      return true;
    }

    let contentType = this.findHeader(data.headers, "content-type");

    return (contentType &&
      contentType.toLowerCase().includes("multipart/form-data;"));
  },

  /**
   * Write out paramters from post data text.
   *
   * @param object postDataText
   *        Post data text.
   * @return string
   *         Post data parameters.
   */
  writePostDataTextParams: function (postDataText) {
    if (!postDataText) {
      return "";
    }
    let lines = postDataText.split("\r\n");
    return lines[lines.length - 1];
  },

  /**
   * Finds the header with the given name in the headers array.
   *
   * @param array headers
   *        Array of headers info {name:x, value:x}.
   * @param string name
   *        The header name to find.
   * @return string
   *         The found header value or null if not found.
   */
  findHeader: function (headers, name) {
    if (!headers) {
      return null;
    }

    name = name.toLowerCase();
    for (let header of headers) {
      if (name == header.name.toLowerCase()) {
        return header.value;
      }
    }

    return null;
  },

  /**
   * Returns the boundary string for a multipart request.
   *
   * @param string data
   *        The data source. See the description in the Curl object.
   * @return string
   *         The boundary string for the request.
   */
  getMultipartBoundary: function (data) {
    let boundaryRe = /\bboundary=(-{3,}\w+)/i;

    // Get the boundary string from the Content-Type request header.
    let contentType = this.findHeader(data.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 = data.postDataText.match(boundaryRe)[1];
    if (boundaryString) {
      return boundaryString;
    }

    return null;
  },

  /**
   * Removes the binary data from multipart text.
   *
   * @param string multipartText
   *        Multipart form data text.
   * @param string boundary
   *        The boundary string.
   * @return string
   *         The multipart text without the binary data.
   */
  removeBinaryDataFromMultipartText: function (multipartText, boundary) {
    let result = "";
    boundary = "--" + boundary;
    let parts = multipartText.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.includes("content-disposition: form-data")) {
        if (contentDispositionLine.includes("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 += boundary + "--\r\n";

    return result;
  },

  /**
   * Get the headers from a multipart post data text.
   *
   * @param string multipartText
   *        Multipart post text.
   * @return array
   *         An array of header objects {name:x, value:x}
   */
  getHeadersFromMultipartText: function (multipartText) {
    let headers = [];
    if (!multipartText || multipartText.startsWith("---")) {
      return headers;
    }

    // Get the header section.
    let index = multipartText.indexOf("\r\n\r\n");
    if (index == -1) {
      return headers;
    }

    // Parse the header lines.
    let headersText = multipartText.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(/!/g, "\\041")
                        .replace(/[^\x20-\x7E]/g, escapeCharacter) + "'";
    }

    // Use single quote syntax.
    return "'" + str + "'";
  },

  /**
   * Escape util function for Windows systems.
   */
  escapeStringWin: function (str) {
    const encapsChars = '"';
    return (
      encapsChars +
      str
        // Replace \ with \\ first because it is an escape character for certain
        // conditions in both parsers.
        .replace(/\\/g, "\\\\")
        // Replace double quote chars with two double quotes (not by escaping with \")
        // because it is recognized by both cmd.exe and MS CRT arguments parser.
        .replace(/"/g, '""')
        // Escape ` and $ so commands do not get executed e.g $(calc.exe) or `\$(calc.exe)
        .replace(/[`$]/g, "\\$&")
        // Then escape all characters we are not sure about with ^ to ensure it
        // gets to the MS CRT parser safely.
        // Note: Do not escape unicode control non-printable characters (0000-001f + 007f-009f)
        .replace(/[^a-zA-Z0-9\s_\-:=+~\/.',?;()*\$&\\{}\"`\u0000-\u001f\u007f-\u009f]/g, "^$&")
        // The % character is special because MS CRT parser will try and look for
        // ENV variables and fill them in its place. We cannot escape them with %
        // and cannot escape them with ^ (because it's cmd.exe's escape not MS CRT
        // parser); So we can get cmd.exe parser to escape the character after it,
        // if it is followed by a valid beginning character of an ENV variable.
        // This ensures we do not try and double escape another ^ if it was placed
        // by the previous replace.
        .replace(/%(?=[a-zA-Z0-9_])/g, "%^")
        // All other whitespace characters are replaced with a single space, as there
        // is no way to enter their literal values in a command line, and they do break
        // the command sequence, allowing for injection.
        // Since want to keep line breaks, we need to exclude them in the regex (`[^\r\n]`),
        // and use double negations to get the other whitespace chars (`[^\S]` translates
        // to "not not whitespace")
        .replace(/[^\S\r\n]/g, " ")
        // Lastly we replace new lines with ^ and TWO new lines because the first
        // new line is there to enact the escape command the second is the character
        // to escape (in this case new line).
        .replace(/\r?\n|\r/g, "^\n\n")
      + encapsChars
    );
  }
};

exports.CurlUtils = CurlUtils;
