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}