Rust in Everything
基于 reqwest 0.13.5

依赖开了 native-tls,默认后端就换了

主线位置:连接主线的 TLS 一步,决定却在 ClientBuilder::build 里:config.tls 的默认值由编译进来的 feature 决定。后端怎么选、两个后端各自怎么配,见 TLS 配置。本页讲这条规则在依赖图里怎么咬人。

flowchart TB
  app["应用 reqwest 默认"] --> uni["feature 并集"]
  dep["某个依赖 native-tls"] --> uni
  uni --> both["rustls 加 native-tls"]
  both --> def["TlsBackend default"]
  def --> nt["NativeTls"]
  nt --> s1["PEM 证书报错"]
  nt --> s2["TLS 1.3 下限失败"]
  nt --> s3["TlsInfo 无版本"]
  fix["tls_backend_rustls"] --> rs["Rustls"]

实验 crate:exp-reqwest/pitb-native(应用)和 exp-reqwest/pitb-dep(替身依赖)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端一律 .no_proxy()。服务器是 testsrv 的 TLS 模式(rustls 服务端,ALPN h2、http/1.1),客户端用 tls_certs_only 只信它的测试 CA。

现象

应用自己写的是 reqwest = { version = "=0.13.5", features = ["json"] },按 0.13 的默认,后端是 rustls。pitb-dep 是一个替身,代表某个 SDK,它的 Cargo.toml 里只有一行:

reqwest = { version = "=0.13.5", features = ["native-tls"] }

应用的代码一个字不改,同一个程序编两次:不链接 pitb-dep,和链接 pitb-dep(--features dep;应用代码从不调用它)。程序建四个客户端:什么都不配;加一个 PEM 格式的客户端证书;设 TLS 1.3 下限;显式 tls_backend_rustls() 再设 TLS 1.3 下限:

  go("plain builder", base(), &url).await;
  go("identity(from_pem)", base().identity(Identity::from_pem(pem.as_bytes()).unwrap()), &url).await;
  go("tls_version_min(1.3)", base().tls_version_min(tls::Version::TLS_1_3), &url).await;
  go("tls_backend_rustls + 1.3", base().tls_backend_rustls().tls_version_min(tls::Version::TLS_1_3), &url).await;

不链接 pitb-dep:

pitb-dep linked: false
plain builder: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
identity(from_pem): 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
tls_version_min(1.3): 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
tls_backend_rustls + 1.3: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))

链接 pitb-dep:

pitb-dep linked: true
plain builder: 200 OK HTTP/2.0, TlsInfo version None
identity(from_pem): build: builder error <- incompatible TLS identity type
tls_version_min(1.3): send: error sending request for url (https://localhost:50116/) <- client error (Connect) <- illegal parameter
  innermost: Error { code: -9830, message: "illegal parameter" }
tls_backend_rustls + 1.3: 200 OK HTTP/2.0, TlsInfo version Some(Version(Tls1_3))
  • 请求照样成功,但换了实现。 第一行仍是 200 OK,TlsInfo 的版本却成了 None:走的是 native-tls(macOS 上是 Security.framework),这个后端从不报告版本。它在这台机器上只谈 TLS 1.2,ClientHello 的样子见 TLS 配置。
  • PEM 客户端证书在 build() 失败,报 builder error,source 是 incompatible TLS identity type。Identity::from_pem 是给 rustls 用的格式,native-tls 不认。
  • TLS 1.3 下限在连接时失败,source 是 illegal parameter,最底层是 Security.framework 的错误码 -9830。
  • 第四个客户端显式选了 rustls,两次构建结果一样。

依赖只是被链接进来,从头到尾没有被调用过。

原因

Cargo 对同一个包的 feature 取并集。依赖图里只有一份 reqwest 0.13.5,应用要了默认的 default-tls,pitb-dep 要了 native-tls,编出来的 reqwest 两个后端都有。Cargo.toml 里这两个 feature 的定义见 default-tls 和 native-tls。

两个都在的时候,默认用谁由 TlsBackend::default 决定:

impl Default for TlsBackend {
    fn default() -> TlsBackend {
        #[cfg(any(
            all(feature = "__rustls", not(feature = "__native-tls")),
            feature = "http3"
        ))]
        {
            TlsBackend::Rustls
        }

        #[cfg(all(feature = "__native-tls", not(feature = "http3")))]
        {
            TlsBackend::NativeTls
        }
    }
}

只要 __native-tls 编进来(且没开 http3),默认就是 NativeTls,default-tls 开没开都一样。这样安排的来历见 TLS 配置 里关于 #2752 的冷知识。

三个症状各有出处:

  • build() 里 TlsBackend::NativeTls 分支调 Identity::add_to_native_tls,它只接受 PKCS#12 和 PKCS#8 两种形态,碰到 from_pem 产生的 ClientCert::Pem 就返回 crate::error::builder("incompatible TLS identity type")。
  • 同一个分支把 tls_version_min 翻译成 native_tls::Protocol::Tlsv13 交给 native-tls。tls_version_min 的文档写着:「On Apple platforms, a value of tls::Version::TLS_1_3 may cause requests to fail (with error -9830) with the native-tls backend due to lack of TLS 1.3 support.」上面的 code: -9830 就是它。
  • TlsInfo::version 的文档:「The native-tls backend never reports a version.」

修法

在你建的每个 ClientBuilder 上显式选后端:

let client = reqwest::Client::builder()
  .tls_backend_rustls()
  // ...
  .build()?;

tls_backend_rustls 只是把 config.tls 设成 TlsBackend::Rustls,上面第四行就是它:链接了 pitb-dep 也拿到 TLS 1.3。0.12 里的旧名字 use_rustls_tls() 在 0.13.5 还在,文档注释写着「Deprecated」,实现就是转调 tls_backend_rustls()。tls.rs 的模块文档给的也是这个建议:「If you wish to ensure your Client uses a specific backend, call the appropriate builder methods」。

这只管你自己建的客户端。依赖内部用 Client::new() 建的客户端,同样拿到 native-tls 默认值,你改不了它;要让整个进程都不用 native-tls,只能让那个依赖不打开 reqwest 的 native-tls(看它有没有关掉这个 feature 的开关)。

不想要 native-tls 带来的 OpenSSL / Security.framework 依赖时也是同一个办法:找到开它的依赖。

怎么检查

reqwest 最终开了哪些 feature。 方括号里有 __native-tls,没配置的客户端就是 native-tls:

$ cargo tree --offline --color never -i reqwest -f '{p} [{f}]' --depth 0 --features dep
reqwest v0.13.5 [__native-tls,__native-tls-alpn,__rustls,__rustls-aws-lc-rs,__tls,blocking,charset,cookies,default,default-tls,form,gzip,hickory-dns,http2,json,multipart,native-tls,query,rustls,socks,stream,system-proxy]
$ cargo tree --offline --color never -i reqwest -f '{p} [{f}]' --depth 0
reqwest v0.13.5 [__rustls,__rustls-aws-lc-rs,__tls,blocking,charset,cookies,default,default-tls,form,gzip,hickory-dns,http2,json,multipart,query,rustls,socks,stream,system-proxy]

(blocking、cookies 这些来自实验用的 testsrv,同样是并集的结果。)

是谁开的。 反查 feature 的来路:

$ cargo tree --offline --color never --features dep -e features -i reqwest
...
├── reqwest feature "__native-tls"
│   └── reqwest feature "native-tls"
│       └── pitb-dep v0.1.0 (.../pitb-dep)
│           └── pitb-dep feature "default"
│               └── pitb-native v0.1.0 (.../pitb-native)
...

运行时。 打开 tls_info(true),HTTPS 响应上的 TlsInfo::version() 是 None,就是 native-tls 握的手。

本站锁的 0.12.28 也是两个后端都编进来,实测默认走 native-tls,升级 0.13 以后这条规则不变,见 本站的 reqwest。

小结

  • 依赖树里任何一个 crate 打开 reqwest 的 native-tls,TlsBackend::default() 就是 NativeTls,default-tls 开着也没用;应用代码不用调用那个依赖。
  • 症状不一定是失败:请求照样 200,但 TlsInfo 没有版本、macOS 上只谈 TLS 1.2;PEM 客户端证书报 incompatible TLS identity type;TLS 1.3 下限报 -9830。
  • 修法:自己建的每个 builder 都调 tls_backend_rustls();要整个进程不用 native-tls,得让那个依赖关掉它。
  • 检查:cargo tree -i reqwest -f '{p} [{f}]' --depth 0 看有没有 __native-tls,-e features -i reqwest 找来源。

💬 关联讨论

发起讨论
加载中...