Repository: ducaale/xh (rust)

Issue:
Option to force TLS version
HTTPie has an option to force TLS version. I think it would be useful if xh also has this.

see also: [HTTPie documentation](https://httpie.io/docs#ssl-version)

Pull request description:
Add --ssl option for forcing a TLS version
Resolves #120.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 92f8a27..141d3c8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -451,7 +451,9 @@ dependencies = [
  "encode_unicode",
  "lazy_static",
  "libc",
+ "regex",
  "terminal_size",
+ "unicode-width",
  "winapi",
 ]
 
@@ -461,17 +463,6 @@ version = "0.4.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7"
 
-[[package]]
-name = "cookie"
-version = "0.14.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951"
-dependencies = [
- "percent-encoding",
- "time",
- "version_check",
-]
-
 [[package]]
 name = "cookie"
 version = "0.15.1"
@@ -483,32 +474,16 @@ dependencies = [
  "version_check",
 ]
 
-[[package]]
-name = "cookie_store"
-version = "0.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3818dfca4b0cb5211a659bbcbb94225b7127407b2b135e650d717bfb78ab10d3"
-dependencies = [
- "cookie 0.14.4",
- "idna",
- "log",
- "publicsuffix 1.5.6",
- "serde",
- "serde_json",
- "time",
- "url",
-]
-
 [[package]]
 name = "cookie_store"
 version = "0.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "55b4ac5559dd39f7bdc516f769cb412b151585d8886d216871a8435ed7f862cd"
 dependencies = [
- "cookie 0.15.1",
+ "cookie",
  "idna",
  "log",
- "publicsuffix 2.1.1",
+ "publicsuffix",
  "serde",
  "serde_json",
  "time",
@@ -1078,9 +1053,9 @@ dependencies = [
 
 [[package]]
 name = "indicatif"
-version = "0.16.2"
+version = "0.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b"
+checksum = "7baab56125e25686df467fe470785512329883aab42696d661247aca2a2896e4"
 dependencies = [
  "console",
  "lazy_static",
@@ -1426,9 +1401,9 @@ dependencies = [
 
 [[package]]
 name = "number_prefix"
-version = "0.4.0"
+version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a"
 
 [[package]]
 name = "once_cell"
@@ -1717,16 +1692,6 @@ version = "2.0.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "66b398073e7cdd6f05934389a8f5961e3aabfa66675b6f440df4e2c793d51a4f"
 
-[[package]]
-name = "publicsuffix"
-version = "1.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95b4ce31ff0a27d93c8de1849cf58162283752f065a90d508f1105fa6c9a213f"
-dependencies = [
- "idna",
- "url",
-]
-
 [[package]]
 name = "publicsuffix"
 version = "2.1.1"
@@ -1850,15 +1815,15 @@ dependencies = [
 
 [[package]]
 name = "reqwest"
-version = "0.11.5"
+version = "0.11.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51c732d463dd300362ffb44b7b125f299c23d2990411a4253824630ebc7467fb"
+checksum = "66d2927ca2f685faf0fc620ac4834690d29e7abb153add10f5812eef20b5e280"
 dependencies = [
  "async-compression",
  "base64",
  "bytes",
- "cookie 0.14.4",
- "cookie_store 0.12.0",
+ "cookie",
+ "cookie_store",
  "encoding_rs",
  "futures-core",
  "futures-util",
@@ -2857,8 +2822,8 @@ dependencies = [
  "atty",
  "base64",
  "bytes",
- "cookie 0.15.1",
- "cookie_store 0.15.0",
+ "cookie",
+ "cookie_store",
  "curl",
  "dirs",
  "encoding_rs",
diff --git a/Cargo.toml b/Cargo.toml
index 68b6bb2..27f54cd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,7 @@ dirs = "3.0.1"
 encoding_rs = "0.8.28"
 encoding_rs_io = "0.1.7"
 humantime = "2.1.0"
-indicatif = "0.16.2"
+indicatif = "0.15.0"
 jsonxf = "1.1.0"
 mime = "0.3.16"
 mime2ext = "0.1.0"
@@ -43,7 +43,7 @@ time = "0.2.26"
 url = "2.2.2"
 
 [dependencies.reqwest]
-version = "0.11.5"
+version = "0.11.6"
 default-features = false
 features = ["rustls-tls", "json", "gzip", "brotli", "deflate", "multipart", "blocking", "socks", "cookies"]
 
diff --git a/src/cli.rs b/src/cli.rs
index f7f206c..551898a 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -11,7 +11,7 @@ use std::time::Duration;
 
 use anyhow::anyhow;
 use encoding_rs::Encoding;
-use reqwest::{Method, Url};
+use reqwest::{tls, Method, Url};
 use serde::{Deserialize, Serialize};
 use structopt::clap::{self, arg_enum, AppSettings, Error, ErrorKind, Result};
 use structopt::StructOpt;
@@ -240,6 +240,16 @@ pub struct Cli {
     #[structopt(long, value_name = "FILE", parse(from_os_str))]
     pub cert_key: Option<PathBuf>,
 
+    /// Force a particular TLS version.
+    ///
+    /// "auto" or "ssl2.3" gives the default behavior of negotiating a version
+    /// with the server.
+    #[structopt(long, value_name = "VERSION", parse(from_str = parse_tls_version),
+      possible_values = &["auto", "ssl2.3", "tls1", "tls1.1", "tls1.2", "tls1.3"])]
+    // The nested option is weird, but parse_tls_version can return None.
+    // If the inner option doesn't use a qualified path structopt gets confused.
+    pub ssl: Option<std::option::Option<tls::Version>>,
+
     /// Use the system TLS library instead of rustls (if enabled at compile time).
     #[structopt(long)]
     pub native_tls: bool,
@@ -355,6 +365,7 @@ const NEGATION_FLAGS: &[&str] = &[
     "--no-quiet",
     "--no-session",
     "--no-session-read-only",
+    "--no-ssl",
     "--no-stream",
     "--no-style",
     "--no-timeout",
@@ -718,6 +729,19 @@ arg_enum! {
     }
 }
 
+/// The caller must check in advance if the string is valid. (structopt does this.)
+fn parse_tls_version(text: &str) -> Option<tls::Version> {
+    match text {
+        // ssl2.3 is not a real version but it's how HTTPie spells "auto"
+        "auto" | "ssl2.3" => None,
+        "tls1" => Some(tls::Version::TLS_1_0),
+        "tls1.1" => Some(tls::Version::TLS_1_1),
+        "tls1.2" => Some(tls::Version::TLS_1_2),
+        "tls1.3" => Some(tls::Version::TLS_1_3),
+        _ => unreachable!(),
+    }
+}
+
 impl Pretty {
     pub fn color(self) -> bool {
         matches!(self, Pretty::colors | Pretty::all)
diff --git a/src/main.rs b/src/main.rs
index a41f96d..dfa5b47 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ use reqwest::header::{
     HeaderValue, ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONNECTION, CONTENT_TYPE, COOKIE, RANGE,
     USER_AGENT,
 };
+use reqwest::tls;
 
 use crate::auth::{auth_from_netrc, parse_auth, read_netrc};
 use crate::buffer::Buffer;
@@ -58,7 +59,8 @@ fn main() {
         }
         Err(err) => {
             eprintln!("{}: error: {:?}", bin_name, err);
-            if !native_tls && err.root_cause().to_string() == "invalid dnsname" {
+            let msg = err.root_cause().to_string();
+            if !native_tls && msg == "invalid dnsname" {
                 eprintln!();
                 if utils::url_requires_native_tls(&url) {
                     eprintln!("rustls does not support HTTPS for IP addresses.");
@@ -74,6 +76,10 @@ fn main() {
                     eprintln!("Consider building with the `native-tls` feature enabled.");
                 }
             }
+            if native_tls && msg == "invalid minimum TLS version for backend" {
+                eprintln!();
+                eprintln!("Try running without the --native-tls flag.");
+            }
             process::exit(1);
         }
     }
@@ -121,6 +127,23 @@ fn run(args: Cli) -> Result<i32> {
         .redirect(reqwest::redirect::Policy::none())
         .timeout(timeout);
 
+    if let Some(Some(tls_version)) = args.ssl {
+        client = client
+            .min_tls_version(tls_version)
+            .max_tls_version(tls_version);
+
+        #[cfg(feature = "native-tls")]
+        if !args.native_tls && tls_version < tls::Version::TLS_1_2 {
+            warn("rustls does not support older TLS versions. native-tls will be enabled. Use --native-tls to silence this warning.");
+            client = client.use_native_tls();
+        }
+
+        #[cfg(not(feature = "native-tls"))]
+        if tls_version < tls::Version::TLS_1_2 {
+            warn("rustls does not support older TLS versions. Consider building with the `native-tls` feature enabled.");
+        }
+    }
+
     #[cfg(feature = "native-tls")]
     if args.native_tls {
         client = client.use_native_tls();
diff --git a/src/to_curl.rs b/src/to_curl.rs
index d8485fd..d6bffbc 100644
--- a/src/to_curl.rs
+++ b/src/to_curl.rs
@@ -1,7 +1,7 @@
 use std::io::{stderr, stdout, Write};
 
 use anyhow::{anyhow, Result};
-use reqwest::Method;
+use reqwest::{tls, Method};
 
 use crate::{
     cli::{Cli, HttpVersion, Verify},
@@ -59,8 +59,8 @@ impl Command {
         self.env.push((var, value.into()));
     }
 
-    fn warn(&mut self, message: String) {
-        self.warnings.push(message);
+    fn warn(&mut self, message: impl Into<String>) {
+        self.warnings.push(message.into());
     }
 }
 
@@ -170,6 +170,31 @@ pub fn translate(args: Cli) -> Result<Command> {
         cmd.push("--key");
         cmd.push(keyfile.to_string_lossy());
     }
+    if let Some(Some(tls_version)) = args.ssl {
+        match tls_version {
+            tls::Version::TLS_1_0 => {
+                cmd.push("--tlsv1.0");
+                cmd.push("--tls-max");
+                cmd.push("1.0");
+            }
+            tls::Version::TLS_1_1 => {
+                cmd.push("--tlsv1.1");
+                cmd.push("--tls-max");
+                cmd.push("1.1");
+            }
+            tls::Version::TLS_1_2 => {
+                cmd.push("--tlsv1.2");
+                cmd.push("--tls-max");
+                cmd.push("1.2");
+            }
+            tls::Version::TLS_1_3 => {
+                cmd.push("--tlsv1.3");
+                cmd.push("--tls-max");
+                cmd.push("1.3");
+            }
+            _ => unreachable!(),
+        }
+    }
     for proxy in args.proxy {
         match proxy {
             crate::cli::Proxy::All(proxy) => {


Test patch:
diff --git a/tests/cli.rs b/tests/cli.rs
index c032300..9655f63 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -1,4 +1,5 @@
 #![cfg(feature = "integration-tests")]
+#![allow(clippy::bool_assert_comparison)]
 use std::{
     collections::HashSet,
     fs::File,
@@ -11,8 +12,7 @@ use std::{
 use assert_cmd::prelude::*;
 use httpmock::{HttpMockRequest, Method::*, MockServer};
 use indoc::{formatdoc, indoc};
-use predicate::str::contains;
-use predicates::prelude::*;
+use predicates::str::contains;
 use serde_json::json;
 use tempfile::{tempdir, tempfile};
 
@@ -309,7 +309,7 @@ fn proxy_https_proxy() {
 
     get_proxy_command("https", "https", &server.base_url())
         .assert()
-        .stderr(predicate::str::contains("unsuccessful tunnel"))
+        .stderr(contains("unsuccessful tunnel"))
         .failure();
     mock.assert();
 }
@@ -407,7 +407,7 @@ fn proxy_all_proxy() {
 
     get_proxy_command("https", "all", &server.base_url())
         .assert()
-        .stderr(predicate::str::contains("unsuccessful tunnel"))
+        .stderr(contains("unsuccessful tunnel"))
         .failure();
     mock.assert();
 
@@ -977,6 +977,75 @@ fn auto_nativetls() {
         .stderr(predicates::str::contains("native-tls will be enabled"));
 }
 
+#[test]
+fn good_tls_version() {
+    get_command()
+        .arg("--ssl=tls1.2")
+        .arg("https://tls-v1-2.badssl.com:1012/")
+        .assert()
+        .success();
+}
+
+#[cfg(feature = "native-tls")]
+#[test]
+fn good_tls_version_nativetls() {
+    get_command()
+        .arg("--ssl=tls1.1")
+        .arg("--native-tls")
+        .arg("https://tls-v1-1.badssl.com:1011/")
+        .assert()
+        .success();
+}
+
+#[test]
+fn bad_tls_version() {
+    get_command()
+        .arg("--ssl=tls1.3")
+        .arg("https://tls-v1-2.badssl.com:1012/")
+        .assert()
+        .failure();
+}
+
+#[cfg(feature = "native-tls")]
+#[test]
+fn bad_tls_version_nativetls() {
+    get_command()
+        .arg("--ssl=tls1.1")
+        .arg("--native-tls")
+        .arg("https://tls-v1-2.badssl.com:1012/")
+        .assert()
+        .failure();
+}
+
+#[cfg(feature = "native-tls")]
+#[test]
+fn unsupported_tls_version_nativetls() {
+    get_command()
+        .arg("--ssl=tls1.3")
+        .arg("--native-tls")
+        .arg("https://example.org")
+        .assert()
+        .failure()
+        .stderr(contains("invalid minimum TLS version"))
+        .stderr(contains("running without the --native-tls"));
+}
+
+#[test]
+fn unsupported_tls_version_rustls() {
+    #[cfg(feature = "native-tls")]
+    const MSG: &str = "native-tls will be enabled";
+    #[cfg(not(feature = "native-tls"))]
+    const MSG: &str = "Consider building with the `native-tls` feature enabled";
+
+    get_command()
+        .arg("--offline")
+        .arg("--ssl=tls1.1")
+        .arg(":")
+        .assert()
+        .stderr(contains("rustls does not support older TLS versions"))
+        .stderr(contains(MSG));
+}
+
 #[test]
 fn forced_json() {
     let server = MockServer::start();
@@ -1129,7 +1198,7 @@ fn mixed_stdin_request_items() {
         .stdin(input_file)
         .assert()
         .failure()
-        .stderr(predicate::str::contains(
+        .stderr(contains(
             "Request body (from stdin) and request data (key=value) cannot be mixed",
         ));
 }
@@ -1144,9 +1213,7 @@ fn multipart_stdin() {
         .stdin(input_file)
         .assert()
         .failure()
-        .stderr(predicate::str::contains(
-            "Cannot build a multipart request body from stdin",
-        ));
+        .stderr(contains("Cannot build a multipart request body from stdin"));
 }
 
 #[test]
@@ -1298,9 +1365,7 @@ fn no_double_file_body() {
         .arg("@bar")
         .assert()
         .failure()
-        .stderr(predicate::str::contains(
-            "Can't read request from multiple files",
-        ));
+        .stderr(contains("Can't read request from multiple files"));
 }
 
 #[test]
@@ -1321,7 +1386,7 @@ fn print_body_from_file() {
         .arg(format!("@{}", filename.to_string_lossy()))
         .assert()
         .success()
-        .stdout(predicate::str::contains("Hello world"));
+        .stdout(contains("Hello world"));
 }
 
 #[test]
@@ -1332,9 +1397,9 @@ fn colored_headers() {
         .assert()
         .success()
         // Color
-        .stdout(predicate::str::contains("\x1b[4m"))
+        .stdout(contains("\x1b[4m"))
         // Reset
-        .stdout(predicate::str::contains("\x1b[0m"));
+        .stdout(contains("\x1b[0m"));
 }
 
 #[test]
@@ -1345,7 +1410,7 @@ fn colored_body() {
         .arg("x:=3")
         .assert()
         .success()
-        .stdout(predicate::str::contains("\x1b[34m3\x1b[0m"));
+        .stdout(contains("\x1b[34m3\x1b[0m"));
 }
 
 #[test]
@@ -1358,7 +1423,7 @@ fn force_color_pipe() {
         .arg("x:=3")
         .assert()
         .success()
-        .stdout(predicate::str::contains("\x1b[34m3\x1b[0m"));
+        .stdout(contains("\x1b[34m3\x1b[0m"));
 }
 
 #[test]
@@ -2027,9 +2092,7 @@ fn max_redirects_is_enforced() {
         .arg("--follow")
         .arg("--max-redirects=5")
         .assert()
-        .stderr(predicate::str::contains(
-            "Too many redirects (--max-redirects=5)",
-        ))
+        .stderr(contains("Too many redirects (--max-redirects=5)"))
         .failure();
 }