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(pem) = tls.ca_certificate_pem(described)? {
252            // A bundle rather than one certificate: a private CA with an
253            // intermediate is the ordinary enterprise shape, and a file
254            // holding both must trust both.
255            let authorities = reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| {
256                // `error` is `reqwest`'s own rendering of a parse failure. It
257                // names the position and the reason, never the bytes — which
258                // matters less for a certificate than for the key below, and
259                // is checked by a test for both.
260                Error::remote(format!(
261                    "{described}: the CA certificate is not a PEM certificate: {error}"
262                ))
263            })?;
264
265            // Measured, and the reason this check exists: a bundle of bytes
266            // that are not PEM at all parses as **no** certificates rather than
267            // as an error. Adding none of them would leave a program that asked
268            // to trust a private CA trusting only the platform's roots and
269            // never being told — the exact silent success this whole module is
270            // an argument against.
271            if authorities.is_empty() {
272                return Err(Error::remote(format!(
273                    "{described}: the CA certificate is not a PEM certificate: it holds \
274                     no `BEGIN CERTIFICATE` block at all"
275                )));
276            }
277
278            for certificate in authorities {
279                // Additive: the platform's own trust store still applies, so a
280                // deployment that reaches both its private GitLab and
281                // github.com needs one source configuration rather than two.
282                builder = builder.add_root_certificate(certificate);
283            }
284        }
285
286        if let Some((certificate, key)) = tls.client_certificate_pem(described)? {
287            let mut pem = certificate;
288            pem.extend_from_slice(b"\n");
289            pem.extend_from_slice(&key);
290
291            let identity = reqwest::Identity::from_pem(&pem).map_err(|error| {
292                // The only error path in this crate that has a private key in
293                // scope. `reqwest`'s rendering of a bad key does not quote it,
294                // and `a_bad_client_key_is_refused_without_quoting_it` holds
295                // that rather than trusting it.
296                Error::remote(format!(
297                    "{described}: the client certificate and key are not a \
298                     usable PEM pair: {error}"
299                ))
300            })?;
301
302            builder = builder.identity(identity);
303        }
304
305        let client = builder.build().map_err(|error| {
306            Error::remote(format!("{described}: cannot build a TLS client: {error}"))
307        })?;
308
309        Ok(Self {
310            client,
311            described: described.to_owned(),
312            timeout,
313        })
314    }
315
316    /// One request, sent when the first of its two readers is read.
317    ///
318    /// Deferred because the trait's `post` hands the caller a writer for the
319    /// request body and the response readers *at the same time*: the body is
320    /// not complete until the writer is dropped, so the request cannot go out
321    /// in `post` itself.
322    fn exchange(
323        &self,
324        url: &str,
325        headers: impl IntoIterator<Item = impl AsRef<str>>,
326        body: Option<PostBodyDataKind>,
327    ) -> Arc<Mutex<Exchange>> {
328        let mut header_map = reqwest::header::HeaderMap::new();
329
330        for line in headers {
331            // A malformed header line is skipped rather than fatal, which is
332            // what `gix`'s own backend does: `http.extraHeader` is arbitrary
333            // caller configuration and a bad one should not stop a fetch.
334            let Some((name, value)) = line.as_ref().split_once(':') else {
335                continue;
336            };
337
338            if let Ok(name) = reqwest::header::HeaderName::try_from(name) {
339                if let Ok(value) = reqwest::header::HeaderValue::try_from(value.trim()) {
340                    header_map.append(name, value);
341                }
342            }
343        }
344
345        Arc::new(Mutex::new(Exchange::Pending {
346            client: self.client.clone(),
347            described: self.described.clone(),
348            timeout: self.timeout,
349            url: url.to_owned(),
350            headers: header_map,
351            posting: body.is_some(),
352            body: Vec::new(),
353        }))
354    }
355}
356
357/// One request's three halves, and the one place they meet.
358///
359/// `gix` is handed a writer for the request body and two readers for the
360/// response, and reads the headers to the end before touching the body. So the
361/// request goes out when the first reader asks for something, and whichever
362/// reader that was leaves the rest here for the other.
363enum Exchange {
364    Pending {
365        client: reqwest::blocking::Client,
366        described: String,
367        timeout: Duration,
368        url: String,
369        headers: reqwest::header::HeaderMap,
370        posting: bool,
371        body: Vec<u8>,
372    },
373    /// The request went out and the host answered; `headers` is the rendered
374    /// header block and `response` is the body, unread.
375    Answered {
376        headers: Vec<u8>,
377        response: Option<reqwest::blocking::Response>,
378    },
379    /// It failed, and both readers owe the caller the same reason.
380    Failed(String, std::io::ErrorKind),
381}
382
383impl Exchange {
384    /// Sends the request if it has not gone out yet.
385    fn send(&mut self) {
386        let Self::Pending {
387            client,
388            described,
389            timeout,
390            url,
391            headers,
392            posting,
393            body,
394        } = self
395        else {
396            return;
397        };
398
399        let request = if *posting {
400            client.post(url.as_str()).body(std::mem::take(body))
401        } else {
402            client.get(url.as_str())
403        };
404
405        let described = described.clone();
406        let timeout = *timeout;
407
408        *self = match request.headers(std::mem::take(headers)).send() {
409            // The deadline, named as itself. `reqwest` renders this as "error
410            // sending request … operation timed out", which reads as an outage
411            // rather than as the number the caller chose — and the difference
412            // decides whether the answer is to raise `with_timeout` or to go
413            // and look at the host.
414            Err(error) if error.is_timeout() => Self::Failed(
415                format!(
416                    "{described}: the host did not answer within {timeout:?}; \
417                     raise `with_timeout` if that is too short for this repository"
418                ),
419                std::io::ErrorKind::TimedOut,
420            ),
421
422            Err(error) => Self::Failed(
423                // The **chain**, not the top: `reqwest`'s own `Display` for a
424                // failed request is "error sending request for url (…)", and
425                // the reason — "invalid peer certificate: UnknownIssuer" — is
426                // two `source()` calls below it. That reason is the whole
427                // diagnostic value of this feature.
428                //
429                // The url it carries is `gix`'s, built from the remote's own,
430                // so it goes through the same redaction every other message in
431                // this crate does.
432                format!("{described}: {}", redacted(&crate::fetch::chain(&error))),
433                std::io::ErrorKind::Other,
434            ),
435
436            Ok(response) => {
437                let status = response.status();
438
439                if status.is_success() {
440                    let mut rendered = Vec::new();
441
442                    // `name:value\n` per line, which is what `gix` splits on a
443                    // colon and compares case-insensitively.
444                    for (name, value) in response.headers() {
445                        rendered.extend_from_slice(name.as_str().as_bytes());
446                        rendered.push(b':');
447                        rendered.extend_from_slice(value.as_bytes());
448                        rendered.push(b'\n');
449                    }
450
451                    Self::Answered {
452                        headers: rendered,
453                        response: Some(response),
454                    }
455                } else if status.is_redirection() {
456                    Self::Failed(
457                        format!(
458                            "{described}: the host answered {status} — a redirect, which \
459                             this transport does not follow, because a fetch is two \
460                             requests against one base url and only this host knows the \
461                             new one; name the url it is redirecting to",
462                        ),
463                        std::io::ErrorKind::Other,
464                    )
465                } else {
466                    // The classification `gix`'s own backends promise, and the
467                    // one this crate's `Failure::Refused` is built on: 401 is a
468                    // credential that can be replaced, everything else is not.
469                    Self::Failed(
470                        format!("{described}: the host answered HTTP {status}"),
471                        if status == reqwest::StatusCode::UNAUTHORIZED {
472                            std::io::ErrorKind::PermissionDenied
473                        } else if status.is_server_error() {
474                            std::io::ErrorKind::ConnectionAborted
475                        } else {
476                            std::io::ErrorKind::Other
477                        },
478                    )
479                }
480            }
481        };
482    }
483}
484
485/// Which half of an [`Exchange`] a [`Reader`] is.
486#[derive(Clone, Copy, PartialEq, Eq)]
487enum Half {
488    Headers,
489    Body,
490}
491
492/// One half of a response, which sends the request if nobody has yet.
493pub(crate) struct Reader {
494    exchange: Arc<Mutex<Exchange>>,
495    half: Half,
496    /// Taken out of the exchange on the first read. Headers are small enough to
497    /// hold; a body is the pack, and streams.
498    inner: Option<Box<dyn BufRead>>,
499}
500
501impl Reader {
502    fn ready(&mut self) -> std::io::Result<&mut Box<dyn BufRead>> {
503        if self.inner.is_none() {
504            let mut exchange = self
505                .exchange
506                .lock()
507                .unwrap_or_else(std::sync::PoisonError::into_inner);
508
509            exchange.send();
510
511            self.inner = Some(match &mut *exchange {
512                Exchange::Failed(why, kind) => {
513                    return Err(std::io::Error::new(*kind, why.clone()));
514                }
515
516                Exchange::Answered { headers, response } => match self.half {
517                    Half::Headers => Box::new(std::io::Cursor::new(std::mem::take(headers))),
518                    Half::Body => match response.take() {
519                        Some(response) => Box::new(std::io::BufReader::new(response)),
520                        None => Box::new(std::io::empty()),
521                    },
522                },
523
524                // Unreachable: `send` leaves `Answered` or `Failed`. An empty
525                // reader rather than a panic, because this is a library and
526                // `gix` decides the order things are read in.
527                Exchange::Pending { .. } => Box::new(std::io::empty()),
528            });
529        }
530
531        Ok(self.inner.as_mut().expect("just filled in"))
532    }
533}
534
535impl Read for Reader {
536    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
537        self.ready()?.read(buffer)
538    }
539}
540
541impl BufRead for Reader {
542    fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
543        self.ready()?.fill_buf()
544    }
545
546    fn consume(&mut self, amount: usize) {
547        if let Some(inner) = self.inner.as_mut() {
548            inner.consume(amount);
549        }
550    }
551}
552
553/// The request body `gix` writes into, collected until it is complete.
554///
555/// Bounded rather than streamed: a fetch's request body is the `want`/`have`
556/// negotiation, which for one ref at depth 1 is a few hundred bytes. Streaming
557/// it would need a second thread and a pipe to hold the writer open, and this
558/// transport has no push to carry.
559pub(crate) struct Body(Arc<Mutex<Exchange>>);
560
561impl Write for Body {
562    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
563        if let Exchange::Pending { body, .. } = &mut *self
564            .0
565            .lock()
566            .unwrap_or_else(std::sync::PoisonError::into_inner)
567        {
568            body.extend_from_slice(buffer);
569        }
570
571        Ok(buffer.len())
572    }
573
574    fn flush(&mut self) -> std::io::Result<()> {
575        Ok(())
576    }
577}
578
579impl Http for Client {
580    type Headers = Reader;
581    type ResponseBody = Reader;
582    type PostBody = Body;
583
584    fn get(
585        &mut self,
586        url: &str,
587        _base_url: &str,
588        headers: impl IntoIterator<Item = impl AsRef<str>>,
589    ) -> Result<GetResponse<Self::Headers, Self::ResponseBody>, HttpError> {
590        let exchange = self.exchange(url, headers, None);
591
592        Ok(GetResponse {
593            headers: Reader {
594                exchange: Arc::clone(&exchange),
595                half: Half::Headers,
596                inner: None,
597            },
598            body: Reader {
599                exchange,
600                half: Half::Body,
601                inner: None,
602            },
603        })
604    }
605
606    fn post(
607        &mut self,
608        url: &str,
609        _base_url: &str,
610        headers: impl IntoIterator<Item = impl AsRef<str>>,
611        body: PostBodyDataKind,
612    ) -> Result<PostResponse<Self::Headers, Self::ResponseBody, Self::PostBody>, HttpError> {
613        let exchange = self.exchange(url, headers, Some(body));
614
615        Ok(PostResponse {
616            post_body: Body(Arc::clone(&exchange)),
617            headers: Reader {
618                exchange: Arc::clone(&exchange),
619                half: Half::Headers,
620                inner: None,
621            },
622            body: Reader {
623                exchange,
624                half: Half::Body,
625                inner: None,
626            },
627        })
628    }
629
630    fn configure(
631        &mut self,
632        _config: &dyn std::any::Any,
633    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
634        // Nothing to configure. The options `gix` would pass here are the ones
635        // its own reqwest backend also ignores, plus the two SSL fields this
636        // module exists because that backend ignores; taking them and honouring
637        // some of them would make `http.sslCAInfo` work in one transport and
638        // not the other, which is a worse story than "this crate reads its own
639        // configuration and not git's".
640        Ok(())
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    /// The material every test here plants. If it is ever rendered, something
649    /// printed a private key.
650    const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
651
652    fn described() -> &'static str {
653        "git https://gitlab.internal/acme/config.git main:config.yaml"
654    }
655
656    /// A `tls` on an ssh url is a belief about how ssh authenticates a host,
657    /// and it is wrong. Saying so beats configuring nothing.
658    #[test]
659    fn tls_on_a_url_that_has_no_tls_in_it_is_refused() {
660        let tls = TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem");
661
662        for url in [
663            "ssh://git@github.com/acme/config.git",
664            "git@github.com:acme/config.git",
665            "file:///srv/config.git",
666            "http://gitlab.internal/acme/config.git",
667        ] {
668            let error = check_scheme(url, &tls).expect_err("{url} has no TLS to configure");
669
670            assert!(
671                error.to_string().contains("configures the https"),
672                "{error}"
673            );
674        }
675
676        check_scheme("https://gitlab.internal/acme/config.git", &tls)
677            .expect("this one does have TLS in it");
678
679        // And an empty configuration is not a configuration: every url this
680        // crate has ever accepted keeps working.
681        for url in ["ssh://git@github.com/a.git", "file:///srv/config.git"] {
682            check_scheme(url, &TlsConfig::new()).expect("nothing was asked for");
683        }
684    }
685
686    /// The redaction test every new error path in this crate owes. A private
687    /// key that will not parse is the one moment this crate holds one and is
688    /// also about to produce a string.
689    #[test]
690    fn a_bad_client_key_is_refused_without_quoting_it() {
691        let tls = TlsConfig::new().with_client_certificate_pem(
692            "-----BEGIN CERTIFICATE-----\nnot-a-certificate\n-----END CERTIFICATE-----\n",
693            format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
694        );
695
696        let error = Client::new(&tls, Duration::from_secs(5), described())
697            .expect_err("that is not a usable pair");
698
699        let printed = format!("{error} {error:?}");
700
701        assert!(!printed.contains(PLANTED), "{printed}");
702        assert!(printed.contains("not a usable PEM pair"), "{printed}");
703    }
704
705    /// The same, for the CA half: a file of nonsense must name the file rather
706    /// than quote it.
707    #[test]
708    fn a_ca_certificate_that_is_not_one_names_the_setting_and_not_the_bytes() {
709        let tls = TlsConfig::new().with_ca_certificate_pem(format!("not a pem {PLANTED}"));
710
711        let error = Client::new(&tls, Duration::from_secs(5), described())
712            .expect_err("that is not a certificate");
713
714        let printed = format!("{error} {error:?}");
715
716        assert!(!printed.contains(PLANTED), "{printed}");
717        assert!(printed.contains("not a PEM certificate"), "{printed}");
718    }
719
720    /// A missing file is the ordinary operational mistake — a secret that did
721    /// not mount — and it has to name the path.
722    #[test]
723    fn a_ca_file_that_is_not_there_names_the_path() {
724        let tls = TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem");
725
726        let error = Client::new(&tls, Duration::from_secs(5), described())
727            .expect_err("the file is not there");
728
729        assert!(
730            error.to_string().contains("/nonexistent/private-ca.pem"),
731            "{error}"
732        );
733        assert!(error.to_string().contains(described()), "{error}");
734    }
735
736    /// A url carrying a token is quoted into the transport's own failures, and
737    /// `reqwest` renders the url it was given into its error text.
738    #[test]
739    fn a_token_in_a_url_does_not_survive_a_transport_failure() {
740        let client = Client::new(&TlsConfig::new(), Duration::from_millis(200), described())
741            .expect("an empty configuration builds a plain client");
742
743        let response = client.exchange(
744            "https://x-access-token:ghs_hunter2@127.0.0.1:1/acme/config.git/info/refs",
745            ["User-Agent: test"],
746            None,
747        );
748
749        response.lock().unwrap().send();
750
751        let Exchange::Failed(why, _) = &*response.lock().unwrap() else {
752            panic!("nothing is listening on port 1");
753        };
754
755        assert!(!why.contains("hunter2"), "{why}");
756    }
757}