Skip to main content

dynamic_config_git/
tls.rs

1//! HTTPS to a host this machine does not already trust.
2//!
3//! An enterprise GitLab behind a private certificate authority, or a host that
4//! wants a client certificate before it will say hello, is an ordinary
5//! deployment and this crate could not reach either one. The vocabulary is
6//! [`TlsConfig`], which is `dynamic-config-store-core`'s and shared with every
7//! other store crate; what is here is the part that is git's alone, which is
8//! **where that configuration has to be put**.
9//!
10//! # What `gix` allows, measured
11//!
12//! `gix` is configured here with `blocking-http-transport-reqwest-rust-tls` —
13//! pure Rust, no C toolchain and no OpenSSL question, which is why this
14//! workspace has no C dependency. Its HTTP options type,
15//! [`gix::protocol::transport::client::blocking_io::http::Options`], *has* the
16//! two fields this needs — `ssl_ca_info` and `ssl_verify` — and `gix` even maps
17//! `http.sslCAInfo` and `GIT_SSL_CAINFO` onto them. **Only the `curl` backend
18//! reads them.** The `reqwest` backend clones that options struct and uses
19//! three of its fields — the extra headers, the redirect policy and its own
20//! backend hook — and never looks at either of the SSL ones. Its
21//! `reqwest::blocking::Client` is built once, inside a worker thread, from a
22//! `ClientBuilder` with no root store, no identity and no hook to reach either.
23//! Its one extension point, `configure_request`, configures a *request*: a
24//! request has a URL, a method, headers and a body, and no TLS anywhere in it.
25//!
26//! So setting `http.sslCAInfo` through this crate would do nothing at all, and
27//! a store that silently ignores "trust this certificate authority" is a
28//! program that believes it is pinned to a private CA and is not. The C
29//! transport would read it, and adding a C TLS stack to a workspace that has
30//! none — for a crate whose whole argument is that it needs no toolchain — is
31//! the wrong trade.
32//!
33//! # What is here instead
34//!
35//! `gix` takes a transport of the caller's own — that is what
36//! [`Remote::to_connection_with_transport`] is for — and its HTTP transport is
37//! generic over a small [`Http`] trait: three methods over `GET`, `POST` and a
38//! backend hook. So the *git* half stays `gix`'s entirely, including the
39//! handshake, the protocol version, the credential header and the packet
40//! framing; only the seven lines that build an HTTP client are ours, and those
41//! are the seven lines a root store and an identity go into.
42//!
43//! It is used **only when a [`TlsConfig`] was configured**. A source that asks
44//! for nothing new keeps `gix`'s own transport, byte for byte, and nothing on
45//! this page can have broken it.
46//!
47//! # Which knob applies to which transport
48//!
49//! | Reaching | Configured with | Not with |
50//! |---|---|---|
51//! | `https://` | [`Builder::tls`](crate::Builder::tls) — a CA, a client certificate | any `Credential::ssh_*` |
52//! | `ssh://`, `git@host:repo` | [`Credential::ssh_agent`](crate::Credential::ssh_agent), [`ssh_key`](crate::Credential::ssh_key), [`ssh_command`](crate::Credential::ssh_command) | `tls` — `ssh` has its own trust model, in `known_hosts` |
53//! | `file://`, a path | nothing | either |
54//!
55//! They are refused rather than ignored: a `tls` on an `ssh://` url fails at
56//! [`Builder::build`](crate::Builder::build), where the mistake was made.
57//!
58//! # The deadline this transport keeps, and the one `gix`'s cannot
59//!
60//! [`with_timeout`](crate::Builder::with_timeout) is a bound on one fetch, and
61//! most of a fetch is bounded by `gix`'s interrupt flag, which it checks
62//! between packets while negotiating and while receiving a pack. What an
63//! interrupt flag cannot bound is a host that accepts the connection and then
64//! sends nothing: there are no packets for the check to be between. On this
65//! transport the client is ours, so the caller's number is the connect deadline
66//! *and* the stall deadline on every read. On `gix`'s it is neither.
67//!
68//! Closing that on `gix`'s transport is an upstream change, and a small one:
69//! `gix_transport`'s HTTP options already carry a `connect_timeout`, populated
70//! from `gitoxide.http.connectTimeout`, and its `reqwest` backend never reads
71//! it — the same way it never reads `ssl_ca_info`. Only `curl` does. Measured
72//! against `gix-transport` 0.58.1, where the backend hardcodes twenty seconds.
73//!
74//! Two ways to close it from *here* were measured and both were refused:
75//!
76//! - **Through the backend hook.** `gix`'s reqwest backend takes a
77//!   `configure_request` closure, and `reqwest::blocking::Request` has a
78//!   `timeout_mut`, so a deadline could be installed without touching `gix` at
79//!   all. Installing the hook is also what makes that backend treat the request
80//!   as one whose headers must not be replayed, which turns its redirect policy
81//!   into `RejectConfiguredHeaders` — every source would silently stop
82//!   following the redirect `git` itself follows, in exchange for a timeout.
83//! - **Using this transport for every `https://` source.** What `gix`'s
84//!   reqwest backend reads out of the options it is handed is three fields —
85//!   the extra headers, the redirect policy and this hook — so the swap would
86//!   cost `http.extraHeader` and redirect following and nothing else, which is
87//!   less than it sounds. It is still the wrong trade: it puts every caller,
88//!   including every caller whose host answers in milliseconds and who never
89//!   asked for TLS, on this crate's HTTP client to bound a stall that `gix` is
90//!   one field away from bounding itself.
91//!
92//! So the honest thing is to say which transport bounds what, which
93//! [`with_timeout`](crate::Builder::with_timeout) does in a table.
94//!
95//! # It follows no redirect, and that is not the obvious reason
96//!
97//! The obvious reason is that every request here carries an `Authorization`
98//! header and a redirect is a stranger's opportunity to be handed it — and it
99//! is not quite the true one: `reqwest` removes `Authorization` itself when a
100//! redirect crosses to another host, port or scheme, so the token would not
101//! travel. The real reason is what a git fetch *is*.
102//!
103//! Smart HTTP is two requests against one base url: a `GET` of the ref
104//! advertisement and a `POST` of the negotiation. Following a `301` on the
105//! first leaves the second still addressed to the old url — and a `301` on a
106//! `POST` is turned into a `GET` with no body by every HTTP client that obeys
107//! the specification, this one included. Making a redirect work therefore means
108//! rewriting the base url for the rest of the conversation, verifying that what
109//! changed was only the part that may change, and deciding whether the identity
110//! may be reused at the new address. That is `gix`'s `redirect` module and the
111//! bookkeeping around it: security-relevant code, and not worth a second copy
112//! for a transport that is only reached by callers who named an unusual host.
113//! Such a host is named by its final url instead, and the error says so.
114//!
115//! # There is no way to turn verification off
116//!
117//! The reasoning is [`dynamic_config_store_core::tls`]'s and it holds here
118//! unchanged: the two situations anybody reaches for it in — a development
119//! server with a self-signed certificate, an enterprise private CA — are both
120//! *trusting one more certificate*, which is
121//! [`with_ca_certificate_file`](TlsConfig::with_ca_certificate_file) and keeps
122//! the server authenticated. Turning verification off does not make TLS weaker
123//! the way a checklist means; it makes it absent.
124//!
125//! git gives that a second, sharper edge. A fetch presents a credential — the
126//! `Authorization` header this crate puts on every request — before it has
127//! received anything. A connection with no verification is one any party on the
128//! path can terminate, and what they get for it is the token. So the knob a
129//! caller would reach for "just to get past a certificate error in staging" is
130//! the knob that hands a personal access token to whoever is in the way, and
131//! there is no name frightening enough to fix that. `gix`'s own
132//! `gitoxide.http.sslNoVerify` is not reachable from here either, because the
133//! transport on this page is the one being used and it never reads it.
134//!
135//! [`Remote::to_connection_with_transport`]: gix::Remote::to_connection_with_transport
136
137use std::io::{BufRead, Read, Write};
138use std::sync::{Arc, Mutex};
139use std::time::Duration;
140
141use dynamic_config::Error;
142use dynamic_config_store_core::tls::TlsConfig;
143use gix::protocol::transport::client::blocking_io::http::{
144    Error as HttpError, GetResponse, Http, PostBodyDataKind, PostResponse,
145};
146
147use crate::url::redacted;
148
149/// The transport `gix` drives, once a [`TlsConfig`] means it cannot use its
150/// own.
151pub(crate) type Transport = gix::protocol::transport::client::blocking_io::http::Transport<Client>;
152
153/// Refuses a [`TlsConfig`] on a url that has no TLS in it.
154///
155/// A `tls` on `ssh://` is a caller who believes a certificate authority is what
156/// authenticates an SSH host; it is not — `known_hosts` is — and quietly doing
157/// nothing would leave them believing it. Checked at `build`, where it was
158/// written.
159///
160/// # Errors
161///
162/// If `tls` asks for anything and `url` is not `https://`.
163pub(crate) fn check_scheme(url: &str, tls: &TlsConfig) -> Result<(), Error> {
164    if tls.is_empty() || url.starts_with("https://") {
165        return Ok(());
166    }
167
168    Err(Error::remote(format!(
169        "git {}: `tls` configures the https transport, and this url is not an \
170         https one; an ssh remote authenticates its host through `known_hosts` \
171         and its client through a key, which is `Credential::ssh_agent`, \
172         `ssh_key` or `ssh_command`",
173        redacted(url)
174    )))
175}
176
177/// The transport for one fetch, with the caller's trust material in it.
178///
179/// Built per fetch rather than kept, for the same reason the repository is:
180/// a rotated CA file or a re-issued client certificate is picked up by the next
181/// fetch instead of by a restart.
182///
183/// # Errors
184///
185/// If a PEM file cannot be read, if what it holds is not a certificate or a
186/// private key, or if a TLS client cannot be built from them. No message
187/// carries any of the material — see [`described`](Self).
188pub(crate) fn transport(
189    url: &gix::Url,
190    version: gix::protocol::transport::Protocol,
191    tls: &TlsConfig,
192    timeout: Duration,
193    trace: bool,
194    described: &str,
195) -> Result<Transport, Error> {
196    Ok(Transport::new_http(
197        Client::new(tls, timeout, described)?,
198        url.clone(),
199        version,
200        trace,
201    ))
202}
203
204/// A `reqwest` client, wearing the one trait `gix`'s HTTP transport needs.
205pub(crate) struct Client {
206    client: reqwest::blocking::Client,
207    /// The source's `describe()`, for the errors this half raises itself.
208    described: String,
209    /// The caller's [`with_timeout`](crate::Builder::with_timeout), kept so a
210    /// request that hit it can say which number it hit.
211    timeout: Duration,
212}
213
214// Hand-written for the reason every `Debug` in this crate is. `reqwest`'s own
215// does not print an identity today, and this type is not the place to depend on
216// that staying true: the client here may be holding a private key.
217impl std::fmt::Debug for Client {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("Client")
220            .field("for", &self.described)
221            .finish_non_exhaustive()
222    }
223}
224
225impl Client {
226    fn new(tls: &TlsConfig, timeout: Duration, described: &str) -> Result<Self, Error> {
227        let mut builder = reqwest::blocking::ClientBuilder::new()
228            // `gix`'s own reqwest transport hardcodes twenty seconds here and
229            // exposes no way to change it, which is the one part of
230            // `with_timeout` this crate has always had to document as not
231            // covered. On this path it is covered.
232            .connect_timeout(timeout)
233            // And so is the part a connect deadline does not reach. `reqwest`
234            // applies this to the connect, to the response head, and to each
235            // read of the body — a stall bound rather than a budget for the
236            // whole transfer, so a large pack over a slow link is not cut off
237            // while a host that accepts the connection and then says nothing is
238            // given up on. That case is otherwise unbounded: `gix`'s interrupt
239            // flag is checked between packets, and a host that sends none never
240            // reaches a between.
241            .timeout(timeout)
242            .http1_title_case_headers()
243            // **No redirects, deliberately.** Not because the credential would
244            // travel — `reqwest` strips `Authorization` across a change of
245            // host, port or scheme — but because a fetch is two requests
246            // against one base url, and following a redirect on the first
247            // leaves the second addressed to the old one. See the module
248            // documentation for what making it work would actually cost.
249            .redirect(reqwest::redirect::Policy::none());
250
251        if let Some(name) = tls.server_name() {
252            return Err(dynamic_config_store_core::tls::unsupported(
253                described,
254                &format!("verifying against the name {name:?} rather than the address's"),
255                "`reqwest` takes no name override; clone from the name the \
256                 certificate carries",
257            ));
258        }
259
260        if tls.skips_verification() {
261            // One line here, and the only line in this file that decides
262            // whether the rest of it means anything.
263            builder = builder.danger_accept_invalid_certs(true);
264        }
265
266        if let Some(pem) = tls.ca_certificate_pem(described)? {
267            // A bundle rather than one certificate: a private CA with an
268            // intermediate is the ordinary enterprise shape, and a file
269            // holding both must trust both.
270            let authorities = reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| {
271                // `error` is `reqwest`'s own rendering of a parse failure. It
272                // names the position and the reason, never the bytes — which
273                // matters less for a certificate than for the key below, and
274                // is checked by a test for both.
275                Error::remote(format!(
276                    "{described}: the CA certificate is not a PEM certificate: {error}"
277                ))
278            })?;
279
280            // Measured, and the reason this check exists: a bundle of bytes
281            // that are not PEM at all parses as **no** certificates rather than
282            // as an error. Adding none of them would leave a program that asked
283            // to trust a private CA trusting only the platform's roots and
284            // never being told — the exact silent success this whole module is
285            // an argument against.
286            if authorities.is_empty() {
287                return Err(Error::remote(format!(
288                    "{described}: the CA certificate is not a PEM certificate: it holds \
289                     no `BEGIN CERTIFICATE` block at all"
290                )));
291            }
292
293            for certificate in authorities {
294                // Additive: the platform's own trust store still applies, so a
295                // deployment that reaches both its private GitLab and
296                // github.com needs one source configuration rather than two.
297                builder = builder.add_root_certificate(certificate);
298            }
299        }
300
301        if let Some((certificate, key)) = tls.client_certificate_pem(described)? {
302            let mut pem = certificate;
303            pem.extend_from_slice(b"\n");
304            pem.extend_from_slice(&key);
305
306            let identity = reqwest::Identity::from_pem(&pem).map_err(|error| {
307                // The only error path in this crate that has a private key in
308                // scope. `reqwest`'s rendering of a bad key does not quote it,
309                // and `a_bad_client_key_is_refused_without_quoting_it` holds
310                // that rather than trusting it.
311                Error::remote(format!(
312                    "{described}: the client certificate and key are not a \
313                     usable PEM pair: {error}"
314                ))
315            })?;
316
317            builder = builder.identity(identity);
318        }
319
320        let client = builder.build().map_err(|error| {
321            Error::remote(format!("{described}: cannot build a TLS client: {error}"))
322        })?;
323
324        Ok(Self {
325            client,
326            described: described.to_owned(),
327            timeout,
328        })
329    }
330
331    /// One request, sent when the first of its two readers is read.
332    ///
333    /// Deferred because the trait's `post` hands the caller a writer for the
334    /// request body and the response readers *at the same time*: the body is
335    /// not complete until the writer is dropped, so the request cannot go out
336    /// in `post` itself.
337    fn exchange(
338        &self,
339        url: &str,
340        headers: impl IntoIterator<Item = impl AsRef<str>>,
341        body: Option<PostBodyDataKind>,
342    ) -> Arc<Mutex<Exchange>> {
343        let mut header_map = reqwest::header::HeaderMap::new();
344
345        for line in headers {
346            // A malformed header line is skipped rather than fatal, which is
347            // what `gix`'s own backend does: `http.extraHeader` is arbitrary
348            // caller configuration and a bad one should not stop a fetch.
349            let Some((name, value)) = line.as_ref().split_once(':') else {
350                continue;
351            };
352
353            if let Ok(name) = reqwest::header::HeaderName::try_from(name) {
354                if let Ok(value) = reqwest::header::HeaderValue::try_from(value.trim()) {
355                    header_map.append(name, value);
356                }
357            }
358        }
359
360        Arc::new(Mutex::new(Exchange::Pending {
361            client: self.client.clone(),
362            described: self.described.clone(),
363            timeout: self.timeout,
364            url: url.to_owned(),
365            headers: header_map,
366            posting: body.is_some(),
367            body: Vec::new(),
368        }))
369    }
370}
371
372/// One request's three halves, and the one place they meet.
373///
374/// `gix` is handed a writer for the request body and two readers for the
375/// response, and reads the headers to the end before touching the body. So the
376/// request goes out when the first reader asks for something, and whichever
377/// reader that was leaves the rest here for the other.
378enum Exchange {
379    Pending {
380        client: reqwest::blocking::Client,
381        described: String,
382        timeout: Duration,
383        url: String,
384        headers: reqwest::header::HeaderMap,
385        posting: bool,
386        body: Vec<u8>,
387    },
388    /// The request went out and the host answered; `headers` is the rendered
389    /// header block and `response` is the body, unread.
390    Answered {
391        headers: Vec<u8>,
392        response: Option<reqwest::blocking::Response>,
393    },
394    /// It failed, and both readers owe the caller the same reason.
395    Failed(String, std::io::ErrorKind),
396}
397
398impl Exchange {
399    /// Sends the request if it has not gone out yet.
400    fn send(&mut self) {
401        let Self::Pending {
402            client,
403            described,
404            timeout,
405            url,
406            headers,
407            posting,
408            body,
409        } = self
410        else {
411            return;
412        };
413
414        let request = if *posting {
415            client.post(url.as_str()).body(std::mem::take(body))
416        } else {
417            client.get(url.as_str())
418        };
419
420        let described = described.clone();
421        let timeout = *timeout;
422
423        *self = match request.headers(std::mem::take(headers)).send() {
424            // The deadline, named as itself. `reqwest` renders this as "error
425            // sending request … operation timed out", which reads as an outage
426            // rather than as the number the caller chose — and the difference
427            // decides whether the answer is to raise `with_timeout` or to go
428            // and look at the host.
429            Err(error) if error.is_timeout() => Self::Failed(
430                format!(
431                    "{described}: the host did not answer within {timeout:?}; \
432                     raise `with_timeout` if that is too short for this repository"
433                ),
434                std::io::ErrorKind::TimedOut,
435            ),
436
437            Err(error) => Self::Failed(
438                // The **chain**, not the top: `reqwest`'s own `Display` for a
439                // failed request is "error sending request for url (…)", and
440                // the reason — "invalid peer certificate: UnknownIssuer" — is
441                // two `source()` calls below it. That reason is the whole
442                // diagnostic value of this feature.
443                //
444                // The url it carries is `gix`'s, built from the remote's own,
445                // so it goes through the same redaction every other message in
446                // this crate does.
447                format!("{described}: {}", redacted(&crate::fetch::chain(&error))),
448                std::io::ErrorKind::Other,
449            ),
450
451            Ok(response) => {
452                let status = response.status();
453
454                if status.is_success() {
455                    let mut rendered = Vec::new();
456
457                    // `name:value\n` per line, which is what `gix` splits on a
458                    // colon and compares case-insensitively.
459                    for (name, value) in response.headers() {
460                        rendered.extend_from_slice(name.as_str().as_bytes());
461                        rendered.push(b':');
462                        rendered.extend_from_slice(value.as_bytes());
463                        rendered.push(b'\n');
464                    }
465
466                    Self::Answered {
467                        headers: rendered,
468                        response: Some(response),
469                    }
470                } else if status.is_redirection() {
471                    Self::Failed(
472                        format!(
473                            "{described}: the host answered {status} — a redirect, which \
474                             this transport does not follow, because a fetch is two \
475                             requests against one base url and only this host knows the \
476                             new one; name the url it is redirecting to",
477                        ),
478                        std::io::ErrorKind::Other,
479                    )
480                } else {
481                    // The classification `gix`'s own backends promise, and the
482                    // one this crate's `Failure::Refused` is built on: 401 is a
483                    // credential that can be replaced, everything else is not.
484                    Self::Failed(
485                        format!("{described}: the host answered HTTP {status}"),
486                        if status == reqwest::StatusCode::UNAUTHORIZED {
487                            std::io::ErrorKind::PermissionDenied
488                        } else if status.is_server_error() {
489                            std::io::ErrorKind::ConnectionAborted
490                        } else {
491                            std::io::ErrorKind::Other
492                        },
493                    )
494                }
495            }
496        };
497    }
498}
499
500/// Which half of an [`Exchange`] a [`Reader`] is.
501#[derive(Clone, Copy, PartialEq, Eq)]
502enum Half {
503    Headers,
504    Body,
505}
506
507/// One half of a response, which sends the request if nobody has yet.
508pub(crate) struct Reader {
509    exchange: Arc<Mutex<Exchange>>,
510    half: Half,
511    /// Taken out of the exchange on the first read. Headers are small enough to
512    /// hold; a body is the pack, and streams.
513    inner: Option<Box<dyn BufRead>>,
514}
515
516impl Reader {
517    fn ready(&mut self) -> std::io::Result<&mut Box<dyn BufRead>> {
518        if self.inner.is_none() {
519            let mut exchange = self
520                .exchange
521                .lock()
522                .unwrap_or_else(std::sync::PoisonError::into_inner);
523
524            exchange.send();
525
526            self.inner = Some(match &mut *exchange {
527                Exchange::Failed(why, kind) => {
528                    return Err(std::io::Error::new(*kind, why.clone()));
529                }
530
531                Exchange::Answered { headers, response } => match self.half {
532                    Half::Headers => Box::new(std::io::Cursor::new(std::mem::take(headers))),
533                    Half::Body => match response.take() {
534                        Some(response) => Box::new(std::io::BufReader::new(response)),
535                        None => Box::new(std::io::empty()),
536                    },
537                },
538
539                // Unreachable: `send` leaves `Answered` or `Failed`. An empty
540                // reader rather than a panic, because this is a library and
541                // `gix` decides the order things are read in.
542                Exchange::Pending { .. } => Box::new(std::io::empty()),
543            });
544        }
545
546        Ok(self.inner.as_mut().expect("just filled in"))
547    }
548}
549
550impl Read for Reader {
551    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
552        self.ready()?.read(buffer)
553    }
554}
555
556impl BufRead for Reader {
557    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
558        self.ready()?.fill_buf()
559    }
560
561    fn consume(&mut self, amount: usize) {
562        if let Some(inner) = self.inner.as_mut() {
563            inner.consume(amount);
564        }
565    }
566}
567
568/// The request body `gix` writes into, collected until it is complete.
569///
570/// Bounded rather than streamed: a fetch's request body is the `want`/`have`
571/// negotiation, which for one ref at depth 1 is a few hundred bytes. Streaming
572/// it would need a second thread and a pipe to hold the writer open, and this
573/// transport has no push to carry.
574pub(crate) struct Body(Arc<Mutex<Exchange>>);
575
576impl Write for Body {
577    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
578        if let Exchange::Pending { body, .. } = &mut *self
579            .0
580            .lock()
581            .unwrap_or_else(std::sync::PoisonError::into_inner)
582        {
583            body.extend_from_slice(buffer);
584        }
585
586        Ok(buffer.len())
587    }
588
589    fn flush(&mut self) -> std::io::Result<()> {
590        Ok(())
591    }
592}
593
594impl Http for Client {
595    type Headers = Reader;
596    type ResponseBody = Reader;
597    type PostBody = Body;
598
599    fn get(
600        &mut self,
601        url: &str,
602        _base_url: &str,
603        headers: impl IntoIterator<Item = impl AsRef<str>>,
604    ) -> Result<GetResponse<Self::Headers, Self::ResponseBody>, HttpError> {
605        let exchange = self.exchange(url, headers, None);
606
607        Ok(GetResponse {
608            headers: Reader {
609                exchange: Arc::clone(&exchange),
610                half: Half::Headers,
611                inner: None,
612            },
613            body: Reader {
614                exchange,
615                half: Half::Body,
616                inner: None,
617            },
618        })
619    }
620
621    fn post(
622        &mut self,
623        url: &str,
624        _base_url: &str,
625        headers: impl IntoIterator<Item = impl AsRef<str>>,
626        body: PostBodyDataKind,
627    ) -> Result<PostResponse<Self::Headers, Self::ResponseBody, Self::PostBody>, HttpError> {
628        let exchange = self.exchange(url, headers, Some(body));
629
630        Ok(PostResponse {
631            post_body: Body(Arc::clone(&exchange)),
632            headers: Reader {
633                exchange: Arc::clone(&exchange),
634                half: Half::Headers,
635                inner: None,
636            },
637            body: Reader {
638                exchange,
639                half: Half::Body,
640                inner: None,
641            },
642        })
643    }
644
645    fn configure(
646        &mut self,
647        _config: &dyn std::any::Any,
648    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
649        // Nothing to configure. The options `gix` would pass here are the ones
650        // its own reqwest backend also ignores, plus the two SSL fields this
651        // module exists because that backend ignores; taking them and honouring
652        // some of them would make `http.sslCAInfo` work in one transport and
653        // not the other, which is a worse story than "this crate reads its own
654        // configuration and not git's".
655        Ok(())
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    /// The material every test here plants. If it is ever rendered, something
664    /// printed a private key.
665    const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
666
667    fn described() -> &'static str {
668        "git https://gitlab.internal/acme/config.git main:config.yaml"
669    }
670
671    /// A `tls` on an ssh url is a belief about how ssh authenticates a host,
672    /// and it is wrong. Saying so beats configuring nothing.
673    #[test]
674    fn tls_on_a_url_that_has_no_tls_in_it_is_refused() {
675        let tls = TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem");
676
677        for url in [
678            "ssh://git@github.com/acme/config.git",
679            "git@github.com:acme/config.git",
680            "file:///srv/config.git",
681            "http://gitlab.internal/acme/config.git",
682        ] {
683            let error = check_scheme(url, &tls).expect_err("{url} has no TLS to configure");
684
685            assert!(
686                error.to_string().contains("configures the https"),
687                "{error}"
688            );
689        }
690
691        check_scheme("https://gitlab.internal/acme/config.git", &tls)
692            .expect("this one does have TLS in it");
693
694        // And an empty configuration is not a configuration: every url this
695        // crate has ever accepted keeps working.
696        for url in ["ssh://git@github.com/a.git", "file:///srv/config.git"] {
697            check_scheme(url, &TlsConfig::new()).expect("nothing was asked for");
698        }
699    }
700
701    /// The redaction test every new error path in this crate owes. A private
702    /// key that will not parse is the one moment this crate holds one and is
703    /// also about to produce a string.
704    #[test]
705    fn a_bad_client_key_is_refused_without_quoting_it() {
706        let tls = TlsConfig::new().with_client_certificate_pem(
707            "-----BEGIN CERTIFICATE-----\nnot-a-certificate\n-----END CERTIFICATE-----\n",
708            format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
709        );
710
711        let error = Client::new(&tls, Duration::from_secs(5), described())
712            .expect_err("that is not a usable pair");
713
714        let printed = format!("{error} {error:?}");
715
716        assert!(!printed.contains(PLANTED), "{printed}");
717        assert!(printed.contains("not a usable PEM pair"), "{printed}");
718    }
719
720    /// The same, for the CA half: a file of nonsense must name the file rather
721    /// than quote it.
722    #[test]
723    fn a_ca_certificate_that_is_not_one_names_the_setting_and_not_the_bytes() {
724        let tls = TlsConfig::new().with_ca_certificate_pem(format!("not a pem {PLANTED}"));
725
726        let error = Client::new(&tls, Duration::from_secs(5), described())
727            .expect_err("that is not a certificate");
728
729        let printed = format!("{error} {error:?}");
730
731        assert!(!printed.contains(PLANTED), "{printed}");
732        assert!(printed.contains("not a PEM certificate"), "{printed}");
733    }
734
735    /// A missing file is the ordinary operational mistake — a secret that did
736    /// not mount — and it has to name the path.
737    #[test]
738    fn a_ca_file_that_is_not_there_names_the_path() {
739        let tls = TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem");
740
741        let error = Client::new(&tls, Duration::from_secs(5), described())
742            .expect_err("the file is not there");
743
744        assert!(
745            error.to_string().contains("/nonexistent/private-ca.pem"),
746            "{error}"
747        );
748        assert!(error.to_string().contains(described()), "{error}");
749    }
750
751    /// A url carrying a token is quoted into the transport's own failures, and
752    /// `reqwest` renders the url it was given into its error text.
753    #[test]
754    fn a_token_in_a_url_does_not_survive_a_transport_failure() {
755        let client = Client::new(&TlsConfig::new(), Duration::from_millis(200), described())
756            .expect("an empty configuration builds a plain client");
757
758        let response = client.exchange(
759            "https://x-access-token:ghs_hunter2@127.0.0.1:1/acme/config.git/info/refs",
760            ["User-Agent: test"],
761            None,
762        );
763
764        response.lock().unwrap().send();
765
766        let Exchange::Failed(why, _) = &*response.lock().unwrap() else {
767            panic!("nothing is listening on port 1");
768        };
769
770        assert!(!why.contains("hunter2"), "{why}");
771    }
772}