Skip to main content

sendra_core/http/
client.rs

1//! [`HttpClient`] and [`build_client`]: the connection-pooled client
2//! [`crate::send`]/[`crate::send_prepared`] send through, and its redirect
3//! bookkeeping.
4
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::config::{Config, FollowRedirects};
9use crate::error::SendraError;
10use crate::http::response::RedirectHop;
11
12/// Redirect hops recorded during the request currently in flight through a
13/// given [`HttpClient`].
14///
15/// A `reqwest::redirect::Policy` closure has no way to hand its caller
16/// anything back directly — it only decides follow/stop/error — so this is
17/// the side channel: the policy pushes a hop here as it sees each one, and
18/// [`send_prepared`](crate::send_prepared) drains it right after that request finishes. The client
19/// is built once per run and reused by every request in it (see
20/// [`build_client`]), so the log is cleared at the *start* of each send
21/// rather than trusted to be empty — nothing else empties it, and requests in
22/// a run are sent one at a time, never concurrently, so there is never more
23/// than one request's hops in it at once.
24///
25/// **This assumes strictly sequential sends through one [`HttpClient`].**
26/// There is exactly one log per client, shared by every request that client
27/// ever sends, and a hop is attributed to "whatever is currently between the
28/// clear in `send_prepared` and the drain right after it" — not to any
29/// particular request. Two requests sent concurrently through the same
30/// client would race on that log and could easily come back with each
31/// other's redirect hops, or a merged chain that belongs to neither. Nothing
32/// today does that — `run_requests` in `sendra-cli` awaits each request
33/// before starting the next — but if a future feature sends requests from
34/// one client in parallel (a `--repeat`/retry feature that fires several at
35/// once, say, or any other parallel send path), this mechanism has to change
36/// with it: most likely one log per in-flight request rather than one per
37/// client, or a channel instead of a shared `Vec`.
38type RedirectLog = Arc<Mutex<Vec<RedirectHop>>>;
39
40/// The HTTP client [`send`](crate::send) and [`send_prepared`](crate::send_prepared) send through.
41///
42/// A thin wrapper around `reqwest::Client` rather than a re-export of it, so
43/// that the redirect chain a request's `reqwest::redirect::Policy` observes
44/// has somewhere to be recorded and read back — see [`RedirectLog`]. A
45/// front-end builds one with [`build_client`] and passes it around without
46/// taking a direct dependency on reqwest.
47pub struct HttpClient {
48    pub(super) inner: reqwest::Client,
49    /// See [`RedirectLog`] — in particular, its note on why this only works
50    /// as long as sends through this client stay sequential.
51    pub(super) redirects: RedirectLog,
52    /// The whole-request timeout this client was built with, kept so that
53    /// [`SendraError::Timeout`] can name the limit it hit.
54    ///
55    /// Here rather than passed back down through [`send_prepared`](crate::send_prepared) because
56    /// this is where the limit *is*: reqwest keeps its own copy inside
57    /// `inner` and will not hand it back, and `send_prepared` deliberately
58    /// takes no `&Config` (see its doc comment). The client enforces the
59    /// timeout, so the client is what remembers it.
60    pub(super) timeout: Duration,
61}
62
63/// Build the HTTP client a run sends every one of its requests through.
64///
65/// **Once per run, not once per request.** A `reqwest::Client` owns the
66/// connection pool: the TLS session, the kept-alive TCP connection and the
67/// resolved DNS for a host all live in it, and all of it is thrown away with
68/// the client. Building one per request means a collection of twenty requests
69/// against one API pays twenty TLS handshakes to send twenty requests, which is
70/// most of the wall clock for a run that does nothing else. Built once and
71/// borrowed by every send, the second request onwards reuses the connection the
72/// first opened.
73///
74/// It is a function taking a `&Config` rather than a method on `Config`
75/// because a client is not configuration: it holds sockets, it is cheap to
76/// clone and expensive to rebuild, and it belongs to a *run*, whereas the
77/// config it is built from is a resolved set of values that outlives any
78/// particular one. The config decides six things here — the timeout, the
79/// redirect policy, whether TLS certificates are verified, which proxy (if
80/// any) requests go through, which client certificate (if any) to present
81/// for mutual TLS, and whether cookies received are stored and resent
82/// automatically — and nothing else about the client is configurable;
83/// reqwest's own pool defaults are what a command-line tool wants.
84///
85/// **Cookies are opt-in.** [`Config::cookie_jar`] defaults to `false`,
86/// matching curl's own default of not persisting cookies across requests
87/// unless `-c`/`-b` is passed. When enabled, this hands the client
88/// reqwest's own in-memory jar (`ClientBuilder::cookie_store(true)`) rather
89/// than a jar Sendra owns: there is no persistence to disk and nothing
90/// beyond one invocation to manage, so reqwest's default implementation is
91/// exactly what is needed. A request whose `headers:` already sets `Cookie`
92/// is left alone — reqwest only fills in the jar's `Cookie` header when the
93/// request does not already carry one, confirmed by reading reqwest's own
94/// `CookieService` rather than assumed, so an explicit `Cookie:` header
95/// always wins outright rather than merging with the jar; Sendra raises no
96/// conflict for this the way it
97/// does for `auth:` plus an explicit `Authorization` header, since the two
98/// are not the same field the way `auth:` resolves *into* `Authorization` —
99/// the jar operates beneath any one request's headers, at the client's own
100/// connection machinery. Cookies received in response to that request are
101/// still stored in the jar regardless of the request's own `Cookie` header,
102/// so a later request with no explicit header of its own picks them up.
103///
104/// **The jar sees every hop of a redirect chain, not just the final
105/// response.** reqwest layers its cookie handling *underneath* its
106/// redirect-following — each hop of a chain is a separate request/response
107/// pair the jar's `CookieService` processes on its own, confirmed by
108/// reading reqwest's source rather than assumed — so a `Set-Cookie` on an
109/// intermediate hop is stored just as reliably as one on the final
110/// response, and is even available to *later* hops in the same chain. This
111/// is a genuine advantage over `capture`'s manual `Set-Cookie` capture,
112/// which can only see the final response's headers once redirects have
113/// been followed — see the module doc comment on
114/// [`crate::capture`] for that limitation. For a login flow that redirects
115/// through an intermediate hop before setting its session cookie, the jar
116/// is the only one of the two that can pick it up.
117///
118/// Fails when reqwest cannot construct a client at all (a TLS backend that
119/// will not initialise, say), when [`Config::proxy`] does not parse as a URL
120/// reqwest accepts, or when the client certificate cannot be built — either
121/// because `client_cert`/`client_key` names a file that cannot be read
122/// ([`SendraError::ClientCertIo`]), only one of the pair is set
123/// ([`SendraError::ClientCertIncomplete`]), or the files read do not form a
124/// valid identity ([`SendraError::Client`]) — all fatal to the whole run: a
125/// malformed `proxy:`/`--proxy` value, or an unusable client certificate,
126/// means no request in this run could ever have gone anywhere, same as a
127/// client reqwest itself refuses to build.
128pub fn build_client(config: &Config) -> Result<HttpClient, SendraError> {
129    let redirects: RedirectLog = Arc::new(Mutex::new(Vec::new()));
130
131    let policy = match config.redirects {
132        // `Policy::none()` hands the 3xx response straight back rather than
133        // erroring: a redirect with following disabled is a normal,
134        // inspectable response, not a failure. Our custom policy below is
135        // never consulted in this case, so nothing is logged — which is
136        // exactly right, since there is no chain to show.
137        FollowRedirects::Disabled => reqwest::redirect::Policy::none(),
138
139        // Custom rather than `Policy::limited(max)`, because `limited` has no
140        // way to tell us what it saw: every attempt is a hop this crate wants
141        // to show, whether or not it ends up being followed.
142        FollowRedirects::Follow(max) => {
143            let log = redirects.clone();
144            reqwest::redirect::Policy::custom(move |attempt: reqwest::redirect::Attempt| {
145                log.lock().unwrap().push(RedirectHop {
146                    status: attempt.status().as_u16(),
147                    location: attempt.url().to_string(),
148                });
149
150                // `previous()` does not count the attempt now being decided,
151                // so this matches `Policy::limited`'s own rule: `max` hops are
152                // allowed, and the one that would make it `max + 1` errors.
153                if attempt.previous().len() as u32 >= max {
154                    attempt.error(TooManyRedirects { max })
155                } else {
156                    attempt.follow()
157                }
158            })
159        }
160    };
161
162    // reqwest has no timeout of its own by default, so an unresponsive server
163    // would hang the process indefinitely; the config always supplies one.
164    let mut builder = reqwest::Client::builder()
165        .timeout(config.timeout)
166        .redirect(policy)
167        // Unconditional rather than only-when-true: `false` is exactly
168        // reqwest's own default (verify), so this changes nothing for the
169        // overwhelming majority of runs and there is no third state to
170        // handle.
171        .danger_accept_invalid_certs(config.insecure)
172        // Unconditional for the same reason: `false` is reqwest's own
173        // default (no cookie store), so a run that never asked for
174        // `cookie_jar`/`--cookie-jar` builds exactly the client it always
175        // has. `cookie_store(true)` hands the client reqwest's own
176        // in-memory `Jar` — see this function's doc comment for why that,
177        // rather than a jar Sendra owns, is the right implementation here.
178        .cookie_store(config.cookie_jar);
179
180    if let Some(url) = &config.proxy {
181        // `no_proxy()` first: it turns off reqwest's automatic detection of
182        // the system `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment
183        // variables without touching a proxy added explicitly afterwards —
184        // see the reasoning on `Config::proxy`. An explicit proxy is meant to
185        // be authoritative for the run, not one more candidate layered on
186        // top of whatever the environment happens to say.
187        let proxy = reqwest::Proxy::all(url).map_err(SendraError::Client)?;
188        builder = builder.no_proxy().proxy(proxy);
189    }
190    // No `proxy:`/`--proxy`: say nothing, and reqwest's own default —
191    // reading `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` from the environment —
192    // applies, matching what curl and every other common HTTP tool already
193    // do without being asked.
194
195    if let Some(identity) = client_identity(config)? {
196        builder = builder.identity(identity);
197    }
198
199    let inner = builder.build().map_err(SendraError::Client)?;
200
201    Ok(HttpClient {
202        inner,
203        redirects,
204        timeout: config.timeout,
205    })
206}
207
208/// Build the client certificate identity for mutual TLS, or `None` when
209/// `config` sets neither `client_cert` nor `client_key`.
210///
211/// Reads both files and concatenates them into one buffer — certificate PEM,
212/// then key PEM — because reqwest's `rustls-tls` backend exposes exactly one
213/// identity constructor, [`reqwest::Identity::from_pem`], and it wants both
214/// halves in a single buffer rather than as two arguments. (The two-argument
215/// and PKCS#12 constructors exist on `reqwest::Identity` but are gated behind
216/// the `native-tls` feature, which this workspace does not enable — see
217/// [`Config::client_cert`]'s doc comment.) A file that cannot be read is
218/// [`SendraError::ClientCertIo`], naming the path, before reqwest ever sees
219/// it; a buffer reqwest cannot parse as a valid identity is
220/// [`SendraError::Client`], the same variant every other client-construction
221/// failure here uses.
222///
223/// Exactly one of `client_cert`/`client_key` being set is refused as
224/// [`SendraError::ClientCertIncomplete`] — see that variant's doc comment for
225/// why this is checked here rather than earlier: CLI overrides for one half
226/// and a config value for the other are a valid combination, and both are
227/// folded into `config` before this ever runs.
228fn client_identity(config: &Config) -> Result<Option<reqwest::Identity>, SendraError> {
229    match (&config.client_cert, &config.client_key) {
230        (Some(cert_path), Some(key_path)) => {
231            let mut pem = std::fs::read(cert_path).map_err(|source| SendraError::ClientCertIo {
232                path: cert_path.clone(),
233                source,
234            })?;
235            let key = std::fs::read(key_path).map_err(|source| SendraError::ClientCertIo {
236                path: key_path.clone(),
237                source,
238            })?;
239            pem.push(b'\n');
240            pem.extend_from_slice(&key);
241
242            reqwest::Identity::from_pem(&pem)
243                .map(Some)
244                .map_err(SendraError::Client)
245        }
246        (Some(_), None) => Err(SendraError::ClientCertIncomplete { which: "cert" }),
247        (None, Some(_)) => Err(SendraError::ClientCertIncomplete { which: "key" }),
248        (None, None) => Ok(None),
249    }
250}
251
252/// Raised by the custom redirect policy in [`build_client`] when a chain runs
253/// past the configured maximum.
254///
255/// **Exceeding the limit is an error, the same as reqwest's own default
256/// behaviour today.** A response was never short of one — the chain simply
257/// did not resolve within the hops the config allows — so there is no single
258/// "last response reached" that would not misrepresent what happened, the way
259/// there would be for a hop that landed on a plain 3xx with redirects turned
260/// off entirely. This reaches the caller as [`SendraError::Network`], wrapping
261/// reqwest's own redirect error, exactly like a DNS or TLS failure.
262#[derive(Debug)]
263struct TooManyRedirects {
264    max: u32,
265}
266
267impl std::fmt::Display for TooManyRedirects {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(
270            f,
271            "exceeded the configured maximum of {} redirect(s)",
272            self.max
273        )
274    }
275}
276
277impl std::error::Error for TooManyRedirects {}