iota_sdk_graphql_client/tls.rs
1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Client construction for the constructors that build their own
5//! [`reqwest::Client`].
6//!
7//! The crypto provider and the trust anchors are both chosen by feature.
8//! `tls-ring` and `tls-aws-lc` are what turn on `reqwest`'s own TLS, and they
9//! turn it on with `rustls-no-provider` so that neither choice is made on our
10//! behalf. Without either of them `reqwest` is built with no TLS at all, so an
11//! HTTP-only build needs no provider and reaches a plain-HTTP endpoint as-is.
12
13/// A [`reqwest::ClientBuilder`] carrying this crate's user agent and trust
14/// anchors, and a connect timeout of
15/// [`DEFAULT_CONNECT_TIMEOUT`](crate::client::DEFAULT_CONNECT_TIMEOUT).
16///
17/// With the default features the bundled Mozilla roots are merged into the
18/// platform trust store rather than replacing it, so corporate CAs and
19/// user-installed certificates keep working while the bundled set acts as a
20/// floor. That floor matters because reqwest builds its certificate verifier
21/// eagerly, before it knows whether a request will use TLS at all: on Linux the
22/// platform verifier refuses to build when the system store is empty, which
23/// otherwise breaks plain-HTTP use on images without `ca-certificates`. A build
24/// with no provider feature has no verifier to build and so is unaffected.
25///
26/// Note that merging is a union, not a fallback. A CA the platform has
27/// deliberately distrusted is still accepted if the bundled set carries it.
28/// Build with only `tls-webpki-roots`, or only `tls-native-roots`, to get one
29/// set alone.
30///
31/// Android is the exception: reqwest cannot merge roots there and rejects the
32/// attempt, and its platform verifier aborts the process unless the hosting
33/// application performs a JNI handshake the SDK cannot do on its behalf. The
34/// bundled roots are used on their own instead, whenever they are available.
35#[cfg(not(target_arch = "wasm32"))]
36pub(crate) fn default_http_client_builder() -> reqwest::ClientBuilder {
37 install_default_crypto_provider();
38 apply_roots(
39 reqwest::Client::builder()
40 .user_agent(crate::client::USER_AGENT)
41 .connect_timeout(crate::client::DEFAULT_CONNECT_TIMEOUT),
42 )
43}
44
45/// A [`reqwest::ClientBuilder`] carrying this crate's user agent.
46///
47/// On wasm32 the browser owns certificate verification, so there are no trust
48/// anchors and no crypto provider to configure.
49#[cfg(target_arch = "wasm32")]
50pub(crate) fn default_http_client_builder() -> reqwest::ClientBuilder {
51 reqwest::Client::builder().user_agent(crate::client::USER_AGENT)
52}
53
54/// Select this crate's rustls crypto provider for the process, if nothing has
55/// chosen one already.
56///
57/// A provider feature builds `reqwest` with `rustls-no-provider`, so a provider
58/// has to be installed before any client is built. The first caller wins, so an
59/// application that has already chosen one keeps it.
60///
61/// Does nothing when neither `tls-ring` nor `tls-aws-lc` is enabled.
62#[cfg(not(target_arch = "wasm32"))]
63fn install_default_crypto_provider() {
64 // `install_default` returns `Err` when a provider is already installed,
65 // which is the application's prerogative rather than our problem.
66 #[cfg(feature = "tls-ring")]
67 let _ = rustls::crypto::ring::default_provider().install_default();
68
69 #[cfg(all(feature = "tls-aws-lc", not(feature = "tls-ring")))]
70 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
71}
72
73/// Trust the bundled roots, merged into the platform store where that is
74/// supported. Mirrors the platforms on which reqwest routes extra roots to
75/// `rustls_platform_verifier::Verifier::new_with_extra_roots`; anywhere else it
76/// returns a builder error, so the bundled set is used alone.
77///
78/// Needs a provider feature as well as `tls-webpki-roots`:
79/// `reqwest::Certificate` does not exist without reqwest's TLS.
80#[cfg(all(
81 not(target_arch = "wasm32"),
82 feature = "tls-webpki-roots",
83 any(feature = "tls-ring", feature = "tls-aws-lc")
84))]
85fn apply_roots(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
86 let roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS
87 .iter()
88 .filter_map(|cert| reqwest::Certificate::from_der(cert).ok());
89
90 #[cfg(all(
91 feature = "tls-native-roots",
92 any(all(unix, not(target_os = "android")), target_os = "windows")
93 ))]
94 {
95 builder.tls_certs_merge(roots)
96 }
97 #[cfg(not(all(
98 feature = "tls-native-roots",
99 any(all(unix, not(target_os = "android")), target_os = "windows")
100 )))]
101 {
102 builder.tls_certs_only(roots)
103 }
104}
105
106/// Without `tls-webpki-roots` there is nothing to add, so verification is left
107/// to reqwest and the platform trust store. Also taken when no provider
108/// feature is enabled, where there is no TLS to configure and the bundled roots
109/// are compiled in but unused.
110#[cfg(all(
111 not(target_arch = "wasm32"),
112 not(all(
113 feature = "tls-webpki-roots",
114 any(feature = "tls-ring", feature = "tls-aws-lc")
115 ))
116))]
117fn apply_roots(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
118 builder
119}
120
121/// The scheme of `server` when this build cannot reach it, or `None` when it
122/// can.
123///
124/// Without a provider feature `reqwest` is built with no TLS, so an `https` or
125/// `wss` URL fails at request time with a transport error that names neither
126/// TLS nor the feature that would enable it. Reporting it from the constructor
127/// puts the cause where the caller can act on it.
128///
129/// A URL that does not parse is left for the caller's own parse to report.
130#[cfg(all(
131 not(target_arch = "wasm32"),
132 not(any(feature = "tls-ring", feature = "tls-aws-lc"))
133))]
134pub(crate) fn unsupported_scheme(server: &str) -> Option<String> {
135 let scheme = reqwest::Url::parse(server).ok()?.scheme().to_owned();
136 matches!(scheme.as_str(), "https" | "wss").then_some(scheme)
137}
138
139/// Every scheme this crate accepts is reachable once TLS is compiled in, and on
140/// wasm32 the browser provides it.
141#[cfg(any(target_arch = "wasm32", feature = "tls-ring", feature = "tls-aws-lc"))]
142pub(crate) fn unsupported_scheme(_server: &str) -> Option<String> {
143 None
144}
145
146#[cfg(all(test, not(target_arch = "wasm32")))]
147mod tests {
148 use super::unsupported_scheme;
149
150 #[test]
151 fn plain_schemes_need_no_tls() {
152 assert_eq!(unsupported_scheme("http://127.0.0.1:9125"), None);
153 assert_eq!(unsupported_scheme("ws://127.0.0.1:9125"), None);
154 }
155
156 #[test]
157 fn unparsable_url_is_left_to_the_caller() {
158 assert_eq!(unsupported_scheme("not a url"), None);
159 }
160
161 #[cfg(all(
162 not(target_arch = "wasm32"),
163 not(any(feature = "tls-ring", feature = "tls-aws-lc"))
164 ))]
165 #[test]
166 fn tls_schemes_are_rejected_without_a_provider() {
167 assert_eq!(
168 unsupported_scheme("https://graphql.testnet.iota.cafe").as_deref(),
169 Some("https")
170 );
171 assert_eq!(
172 unsupported_scheme("wss://graphql.testnet.iota.cafe").as_deref(),
173 Some("wss")
174 );
175 }
176
177 #[cfg(any(target_arch = "wasm32", feature = "tls-ring", feature = "tls-aws-lc"))]
178 #[test]
179 fn tls_schemes_are_accepted_with_a_provider() {
180 assert_eq!(
181 unsupported_scheme("https://graphql.testnet.iota.cafe"),
182 None
183 );
184 assert_eq!(unsupported_scheme("wss://graphql.testnet.iota.cafe"), None);
185 }
186}