Repository: sagebind/isahc
Pull request #369 (2022-01-08 19:49:24)

Load system trusted root certs for rustls
Add ability to use trusted root certificates from the OS with rustls automatically. This also might benefit from refactoring our SSL/TLS configuration APIs which always assumed the OS default TLS engine prior. That can be done separately.

codecov[bot]: # [Codecov](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley) Report
> Merging [#369](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley) (c6ac27b) into [master](https://codecov.io/gh/sagebind/isahc/commit/f3b5c3c4d1244b11d336d0caa62f47d76b0e344d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley) (f3b5c3c) will **decrease** coverage by `0.21%`.
> The diff coverage is `18.18%`.

[![Impacted file tree graph](https://codecov.io/gh/sagebind/isahc/pull/369/graphs/tree.svg?width=650&height=150&src=pr&token=X5liRLDMl2&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley)](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley)

```diff
@@            Coverage Diff             @@
##           master     #369      +/-   ##
==========================================
- Coverage   79.34%   79.12%   -0.22%     
==========================================
  Files          51       52       +1     
  Lines        3060     3071      +11     
==========================================
+ Hits         2428     2430       +2     
- Misses        632      641       +9     
```


| [Impacted Files](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley) | Coverage Δ | |
|---|---|---|
| [src/lib.rs](https://codecov.io/gh/sagebind/isahc/pull/369/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley#diff-c3JjL2xpYi5ycw==) | `61.53% <ø> (ø)` | |
| [src/tls.rs](https://codecov.io/gh/sagebind/isahc/pull/369/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley#diff-c3JjL3Rscy5ycw==) | `10.00% <10.00%> (ø)` | |
| [src/client.rs](https://codecov.io/gh/sagebind/isahc/pull/369/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley#diff-c3JjL2NsaWVudC5ycw==) | `79.40% <100.00%> (+0.07%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley). Last update [f3b5c3c...c6ac27b](https://codecov.io/gh/sagebind/isahc/pull/369?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephen+M.+Coakley).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ec64ad9..3722158 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,9 @@ jobs:
           - ubuntu-latest
           - macos-latest
           - windows-latest
+        features:
+          - "http2,text-decoding,cookies,psl,unstable-interceptors,native-tls,static-curl"
+          - "http2,text-decoding,cookies,psl,unstable-interceptors,rustls-tls-native-certs"
     runs-on: ${{ matrix.os }}
     timeout-minutes: 20
     env:
@@ -35,7 +38,7 @@ jobs:
         uses: actions-rs/toolchain@v1
         with:
           profile: minimal
-          toolchain: "1.46.0"
+          toolchain: "1.56"
           default: true
 
       - name: Update Cargo.lock
@@ -55,7 +58,7 @@ jobs:
             cargo-${{ runner.os }}-
 
       - name: Run tests
-        run: cargo test --features ${{ env.FEATURES }},spnego,unstable-interceptors
+        run: cargo test --no-default-features --features ${{ matrix.features }}
 
       - name: Run example program
         run: cargo run --release --example simple
diff --git a/Cargo.toml b/Cargo.toml
index 59712ba..722404f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "isahc"
-version = "1.6.0"
+version = "1.7.0"
 description = "The practical HTTP client that is fun to use."
 authors = ["Stephen M. Coakley <me@stephencoakley.com>"]
 license = "MIT"
@@ -19,25 +19,25 @@ features = ["cookies", "json", "nightly"]
 status = "actively-developed"
 
 [features]
-default = ["http2", "static-curl", "text-decoding"]
+default = ["http2", "native-tls", "static-curl", "text-decoding"]
 cookies = ["httpdate"]
 http2 = ["curl/http2"]
 json = ["serde", "serde_json"]
+native-tls = ["curl/ssl", "curl-sys/ssl"]
 nightly = []
 psl = ["httpdate", "parking_lot", "publicsuffix"]
+rustls-tls = ["rustls-ffi", "curl/rustls", "curl/static-curl"]
+rustls-tls-native-certs = ["rustls-tls", "data-encoding", "rustls-native-certs"]
 spnego = ["curl-sys/spnego"]
 static-curl = ["curl/static-curl"]
 static-ssl = ["curl/static-ssl"]
 text-decoding = ["encoding_rs", "mime"]
-unstable-rustls-tls = ["curl/rustls"]
 unstable-interceptors = []
 
 [dependencies]
 async-channel = "1.4.2"
 castaway = "0.1.1"
 crossbeam-utils = ">=0.7.0, <0.9.0"
-curl = "0.4.42"
-curl-sys = "0.4.52"
 event-listener = "2.3.3"
 futures-lite = "1.10.1"
 http = "0.2.1"
@@ -49,6 +49,18 @@ sluice = "0.5.4"
 url = "2.1"
 waker-fn = "1"
 
+[dependencies.curl]
+version = "0.4.43"
+default-features = false
+
+[dependencies.curl-sys]
+version = "0.4.53"
+default-features = false
+
+[dependencies.data-encoding]
+version = "2"
+optional = true
+
 [dependencies.encoding_rs]
 version = "0.8"
 optional = true
@@ -70,6 +82,14 @@ version = "2.0.6"
 features = ["std"]
 optional = true
 
+[dependencies.rustls-ffi]
+version = "0.8"
+optional = true
+
+[dependencies.rustls-native-certs]
+version = "0.6"
+optional = true
+
 [dependencies.serde]
 version = "1.0"
 optional = true
diff --git a/README.md b/README.md
index e066826..586846d 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ Install via Cargo by adding to your `Cargo.toml` file:
 
 ```toml
 [dependencies]
-isahc = "1.6"
+isahc = "1.7"
 ```
 
 ### Minimum supported Rust version
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 1f8dc0d..0d63943 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -27,12 +27,12 @@ pub(crate) mod dns;
 pub(crate) mod proxy;
 pub(crate) mod redirect;
 pub(crate) mod request;
-pub(crate) mod ssl;
+pub(crate) mod tls;
 
 pub use dial::{Dialer, DialerParseError};
 pub use dns::{DnsCache, ResolveMap};
 pub use redirect::RedirectPolicy;
-pub use ssl::{CaCertificate, ClientCertificate, PrivateKey, SslOption};
+pub use tls::{CaCertificate, ClientCertificate, PrivateKey, SslOption};
 
 /// Provides additional methods when building a request for configuring various
 /// execution-related options on how the request should be sent.
diff --git a/src/config/request.rs b/src/config/request.rs
index 6e6551f..1cc2e3b 100644
--- a/src/config/request.rs
+++ b/src/config/request.rs
@@ -32,19 +32,6 @@ macro_rules! define_request_config {
         }
 
         impl RequestConfig {
-            pub(crate) fn client_defaults() -> Self {
-                Self {
-                    // Always start out with latest compatible HTTP version.
-                    version_negotiation: Some(VersionNegotiation::default()),
-                    // Enable automatic decompression by default for convenience
-                    // (and maintain backwards compatibility).
-                    automatic_decompression: Some(true),
-                    // Erase curl's default auth method of Basic.
-                    authentication: Some(Authentication::default()),
-                    ..Default::default()
-                }
-            }
-
             /// Merge another request configuration into this one. Unspecified
             /// values in this config are replaced with values in the given
             /// config.
@@ -84,7 +71,7 @@ define_request_config! {
     max_download_speed: Option<u64>,
     ssl_client_certificate: Option<ClientCertificate>,
     ssl_ca_certificate: Option<CaCertificate>,
-    ssl_ciphers: Option<ssl::Ciphers>,
+    ssl_ciphers: Option<tls::Ciphers>,
     ssl_options: Option<SslOption>,
     enable_metrics: Option<bool>,
 
@@ -94,6 +81,23 @@ define_request_config! {
     title_case_headers: Option<bool>,
 }
 
+impl RequestConfig {
+    pub(crate) fn client_defaults() -> Self {
+        Self {
+            // Always start out with latest compatible HTTP version.
+            version_negotiation: Some(VersionNegotiation::default()),
+            // Enable automatic decompression by default for convenience
+            // (and maintain backwards compatibility).
+            automatic_decompression: Some(true),
+            // Erase curl's default auth method of Basic.
+            authentication: Some(Authentication::default()),
+            // Set native root certificates if available.
+            ssl_ca_certificate: CaCertificate::native(),
+            ..Default::default()
+        }
+    }
+}
+
 impl SetOpt for RequestConfig {
     fn set_opt<H>(&self, easy: &mut Easy2<H>) -> Result<(), curl::Error> {
         if let Some(timeout) = self.timeout {
diff --git a/src/config/ssl.rs b/src/config/tls.rs
similarity index 82%
rename from src/config/ssl.rs
rename to src/config/tls.rs
index bbcadfa..aa29242 100644
--- a/src/config/ssl.rs
+++ b/src/config/tls.rs
@@ -2,6 +2,7 @@
 
 use super::SetOpt;
 use curl::easy::{Easy2, SslOpt};
+use once_cell::sync::Lazy;
 use std::{
     iter::FromIterator,
     ops::{BitOr, BitOrAssign},
@@ -284,12 +285,58 @@ impl SetOpt for PrivateKey {
 /// A public CA certificate bundle file.
 #[derive(Clone, Debug)]
 pub struct CaCertificate {
-    /// Path to the certificate bundle file. Currently only file paths are
-    /// supported.
-    path: PathBuf,
+    /// The certificate data, either a path or a blob.
+    data: PathOrBlob,
 }
 
 impl CaCertificate {
+    /// Use one or more PEM-encoded certificates in the given byte buffer.
+    ///
+    /// The certificate object takes ownership of the byte buffer. If a borrowed
+    /// type is supplied, such as `&[u8]`, then the bytes will be copied.
+    ///
+    /// The certificates are not parsed or validated here. If a certificate is
+    /// malformed or the format is not supported by the underlying SSL/TLS
+    /// engine, an error will be returned when attempting to send a request
+    /// using the offending certificate.
+    pub fn pem<B>(bytes: B) -> Self
+    where
+        B: Into<Vec<u8>>,
+    {
+        Self {
+            data: PathOrBlob::Blob(bytes.into()),
+        }
+    }
+
+    /// Use one or more DER-encoded certificates stored in memory.
+    ///
+    /// The certificates are not parsed or validated here. If a certificate is
+    /// malformed or the format is not supported by the underlying SSL/TLS
+    /// engine, an error will be returned when attempting to send a request
+    /// using the offending certificate.
+    #[cfg(feature = "data-encoding")]
+    #[allow(dead_code)]
+    pub(crate) fn der_multiple<I, B>(certificates: I) -> Self
+    where
+        I: IntoIterator<Item = B>,
+        B: AsRef<[u8]>,
+    {
+        let mut base64_spec = data_encoding::BASE64.specification();
+        base64_spec.wrap.width = 64;
+        base64_spec.wrap.separator.push_str("\r\n");
+        let base64 = base64_spec.encoding().unwrap();
+
+        let mut pem = String::new();
+
+        for certificate in certificates {
+            pem.push_str("-----BEGIN CERTIFICATE-----\r\n");
+            base64.encode_append(certificate.as_ref(), &mut pem);
+            pem.push_str("-----END CERTIFICATE-----\r\n");
+        }
+
+        Self::pem(pem)
+    }
+
     /// Get a CA certificate from a path to a certificate bundle file.
     ///
     /// The certificate file is not loaded or validated here. If the file does
@@ -298,14 +345,43 @@ impl CaCertificate {
     /// using the offending certificate.
     pub fn file(ca_bundle_path: impl Into<PathBuf>) -> Self {
         Self {
-            path: ca_bundle_path.into(),
+            data: PathOrBlob::Path(ca_bundle_path.into()),
         }
     }
+
+    /// Get native root certificates trusted by the operating system, if any.
+    #[allow(unreachable_code)]
+    pub(crate) fn native() -> Option<Self> {
+        static NATIVE: Lazy<Option<CaCertificate>> = Lazy::new(|| {
+            #[cfg(feature = "rustls-native-certs")]
+            {
+                match rustls_native_certs::load_native_certs() {
+                    Ok(certs) => {
+                        if !certs.is_empty() {
+                            return Some(CaCertificate::der_multiple(
+                                certs.into_iter().map(|cert| cert.0),
+                            ));
+                        }
+                    }
+                    Err(e) => {
+                        log::warn!("failed to load native certificate chain: {}", e);
+                    }
+                }
+            }
+
+            None
+        });
+
+        NATIVE.clone()
+    }
 }
 
 impl SetOpt for CaCertificate {
     fn set_opt<H>(&self, easy: &mut Easy2<H>) -> Result<(), curl::Error> {
-        easy.cainfo(&self.path)
+        match &self.data {
+            PathOrBlob::Path(path) => easy.cainfo(path.as_path()),
+            PathOrBlob::Blob(bytes) => easy.ssl_cainfo_blob(bytes.as_slice()),
+        }
     }
 }
 
diff --git a/src/lib.rs b/src/lib.rs
index c416cbc..1b0a7bd 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -143,7 +143,7 @@
 //!
 //! ```toml
 //! [dependencies.isahc]
-//! version = "1.6"
+//! version = "1.7"
 //! features = ["psl"]
 //! ```
 //!


Test patch:
diff --git a/tests/request_body.rs b/tests/request_body.rs
index 8d2618b..26e2c2f 100644
--- a/tests/request_body.rs
+++ b/tests/request_body.rs
@@ -68,7 +68,6 @@ fn request_with_body_of_unknown_size_uses_chunked_encoding(method: &str) {
     m.request().expect_body(body);
 }
 
-#[ignore]
 #[test_case("GET")]
 #[test_case("HEAD")]
 #[test_case("POST")]