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}