churust_core/http3.rs
1//! HTTP/3 over QUIC (feature `http3`).
2//!
3//! HTTP/3 is a different transport, not a different framing of the same one:
4//! it runs over QUIC on UDP, so it needs its own socket and its own listener
5//! rather than an extra branch inside the TCP engine. That is why this is
6//! started separately from [`App::start`](crate::App::start), and why an
7//! application that wants both runs both.
8//!
9//! ```no_run
10//! use churust_core::{Call, Churust};
11//!
12//! # async fn run() -> std::io::Result<()> {
13//! let app = Churust::server()
14//! // Tell HTTP/1.1 and HTTP/2 clients that h3 is available on the same
15//! // port, so they can upgrade themselves on the next request.
16//! .advertise_http3(8443)
17//! .routing(|r| {
18//! r.get("/", |_c: Call| async { "served over h3" });
19//! })
20//! .build();
21//!
22//! churust_core::http3::serve(
23//! app,
24//! "0.0.0.0:8443".parse().unwrap(),
25//! "cert.pem",
26//! "key.pem",
27//! )
28//! .await
29//! # }
30//! ```
31//!
32//! # How clients find it
33//!
34//! They do not, on their own. A browser reaches a new origin over TCP, and only
35//! moves to h3 if the response says h3 exists. That is the `Alt-Svc` header, and
36//! [`AppBuilder::advertise_http3`](crate::AppBuilder::advertise_http3) is what
37//! sets it. Serving h3 without advertising it means almost nothing will ever
38//! use it.
39//!
40//! # What is supported
41//!
42//! Request routing, headers, request bodies and response bodies, including
43//! streamed ones, through the same pipeline every other transport uses: a
44//! handler cannot tell which transport it is answering.
45//!
46//! WebSockets are not carried over h3. The upgrade mechanism there is Extended
47//! CONNECT from RFC 9220, which is a different handshake from the HTTP/1.1 one
48//! the `ws` feature implements, and pretending otherwise would produce a
49//! connection that fails at the first frame.
50
51#![cfg(feature = "http3")]
52
53use crate::app::App;
54use crate::body::Body;
55use bytes::{Buf, Bytes};
56use http::{HeaderMap, Method, Request, Uri};
57use std::io;
58use std::net::SocketAddr;
59use std::sync::Arc;
60
61/// How long a QUIC connection may sit idle before quinn closes it.
62///
63/// Matches the default `keep_alive_ms`, so an idle connection costs the same
64/// whichever transport it arrived on. `server_config_from_pem` is a free
65/// function with no access to the app config; a caller that needs a different
66/// value builds its own `quinn::ServerConfig` and uses `serve_with_config`.
67const DEFAULT_IDLE_MS: u64 = 75_000;
68
69/// Build a QUIC server configuration from a PEM certificate chain and key.
70///
71/// The ALPN protocol is `h3` and nothing else: a QUIC connection that
72/// negotiated anything else would not be HTTP/3, and there is nothing here to
73/// hand it to.
74///
75/// # Errors
76///
77/// If either file is missing or unreadable, if no private key is found, or if
78/// rustls rejects the pair.
79pub fn server_config_from_pem(cert_path: &str, key_path: &str) -> io::Result<quinn::ServerConfig> {
80 let certs = load_certs(cert_path)?;
81 let key = load_key(key_path)?;
82
83 let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
84 .with_no_client_auth()
85 .with_single_cert(certs, key)
86 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
87 tls.alpn_protocols = vec![b"h3".to_vec()];
88
89 let quic = quinn::crypto::rustls::QuicServerConfig::try_from(tls)
90 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
91 let mut config = quinn::ServerConfig::with_crypto(Arc::new(quic));
92
93 // Bound the connection's life, not just the request's. A permit is held for
94 // as long as the QUIC connection lives, and quinn's default idle timeout is
95 // negotiated with the peer — so a client that opens a connection and then
96 // says nothing at all, or answers one request and lingers, pinned a
97 // `max_connections` slot indefinitely. Neither case involves a request, so
98 // no request-level deadline can reach them.
99 //
100 // Set from the same knob that bounds an idle TCP connection, so one setting
101 // means one thing on both transports.
102 let mut transport = quinn::TransportConfig::default();
103 transport.max_idle_timeout(Some(
104 std::time::Duration::from_millis(DEFAULT_IDLE_MS)
105 .try_into()
106 .expect("idle timeout fits in a QUIC varint"),
107 ));
108 config.transport_config(Arc::new(transport));
109 Ok(config)
110}
111
112fn load_certs(path: &str) -> io::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
113 let file = std::fs::File::open(path)?;
114 let mut reader = std::io::BufReader::new(file);
115 rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()
116}
117
118fn load_key(path: &str) -> io::Result<rustls::pki_types::PrivateKeyDer<'static>> {
119 let file = std::fs::File::open(path)?;
120 let mut reader = std::io::BufReader::new(file);
121 rustls_pemfile::private_key(&mut reader)?
122 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no private key found"))
123}
124
125/// Serve `app` over HTTP/3 on `addr` until the process ends.
126///
127/// # Errors
128///
129/// If the certificate or key cannot be loaded, or the UDP socket cannot be
130/// bound.
131pub async fn serve(app: App, addr: SocketAddr, cert_path: &str, key_path: &str) -> io::Result<()> {
132 let config = server_config_from_pem(cert_path, key_path)?;
133 serve_with_config(app, addr, config).await
134}
135
136/// Serve `app` over HTTP/3 with an already-built QUIC configuration.
137///
138/// # Errors
139///
140/// If the UDP socket cannot be bound.
141pub async fn serve_with_config(
142 app: App,
143 addr: SocketAddr,
144 config: quinn::ServerConfig,
145) -> io::Result<()> {
146 Http3Server::bind(addr, config)?.serve(app).await;
147 Ok(())
148}
149
150/// A bound QUIC listener, not yet serving.
151///
152/// Binding and serving are separate steps so the port can be read back before
153/// anything is accepted. That matters for the ordinary case of binding port 0
154/// and telling something else where to connect, and it is what lets a test know
155/// the address without racing the server.
156pub struct Http3Server {
157 endpoint: quinn::Endpoint,
158}
159
160impl std::fmt::Debug for Http3Server {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 f.debug_struct("Http3Server")
163 .field("local_addr", &self.endpoint.local_addr().ok())
164 .finish_non_exhaustive()
165 }
166}
167
168impl Http3Server {
169 /// Bind a UDP socket for QUIC without serving yet.
170 ///
171 /// # Errors
172 ///
173 /// If the socket cannot be bound.
174 pub fn bind(addr: SocketAddr, config: quinn::ServerConfig) -> io::Result<Self> {
175 Ok(Self {
176 endpoint: quinn::Endpoint::server(config, addr)?,
177 })
178 }
179
180 /// The address actually bound, which is what resolves a port of 0.
181 ///
182 /// # Errors
183 ///
184 /// If the socket cannot report its address.
185 pub fn local_addr(&self) -> io::Result<SocketAddr> {
186 self.endpoint.local_addr()
187 }
188
189 /// Accept connections and serve them through `app` until the endpoint
190 /// closes.
191 pub async fn serve(self, app: App) {
192 accept_loop(app, self.endpoint).await;
193 }
194}
195
196/// Accept QUIC connections until the endpoint closes.
197async fn accept_loop(app: App, endpoint: quinn::Endpoint) {
198 // The same connection budget the TCP engine applies. Without it, enabling
199 // http3 opted a deployment out of `max_connections` entirely — and
200 // `advertise_http3` actively steers clients here.
201 let slots = (app.config().max_connections > 0)
202 .then(|| std::sync::Arc::new(tokio::sync::Semaphore::new(app.config().max_connections)));
203
204 while let Some(incoming) = endpoint.accept().await {
205 let app = app.clone();
206 // Taken before accepting, so excess load waits in QUIC's own queue
207 // rather than as memory in this process. Held for the connection's
208 // life by the task below.
209 let permit = match &slots {
210 Some(sem) => sem.clone().acquire_owned().await.ok(),
211 None => None,
212 };
213 // One task per connection, like the TCP engine: a slow peer must not
214 // hold up the accept loop.
215 tokio::spawn(async move {
216 let _permit = permit;
217 match incoming.await {
218 Ok(connection) => {
219 if let Err(e) = serve_connection(app, connection).await {
220 tracing::debug!(error = %e, "http3 connection ended");
221 }
222 }
223 // A failed handshake on an internet-facing port is constant
224 // background noise, so debug rather than warn, matching how the
225 // TCP engine treats a failed TLS handshake.
226 Err(e) => tracing::debug!(error = %e, "http3 handshake failed"),
227 }
228 });
229 }
230}
231
232/// Run one QUIC connection's request streams through the pipeline.
233async fn serve_connection(
234 app: App,
235 connection: quinn::Connection,
236) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
237 // The peer address, which the TCP engine seeds and this path did not — so
238 // `Call::peer_addr` was `None` and anything keying on it (rate limiting,
239 // audit logs, IP allowlists) treated the entire h3 fleet as one client.
240 let peer = connection.remote_address();
241
242 // h3's default `max_field_section_size` is `VarInt::MAX`, i.e. no bound on
243 // a header block at all, where the TCP engine caps HTTP/1 headers by count
244 // and HTTP/2 by encoded size. Reuse the HTTP/2 knob: it asks the same
245 // question of the same kind of header block.
246 let mut h3 = h3::server::builder()
247 .max_field_section_size(app.config().h2_max_header_list_size as u64)
248 .build(h3_quinn::Connection::new(connection))
249 .await?;
250
251 loop {
252 match h3.accept().await {
253 Ok(Some(resolver)) => {
254 let app = app.clone();
255 // One task per request: h3 multiplexes streams on one
256 // connection, so serving them in sequence would make a slow
257 // handler block every other request on that connection.
258 //
259 // Resolving belongs inside the task for the same reason, and
260 // for a second one. `accept` returns as soon as a bidi stream
261 // exists, before any frame has been read, so awaiting
262 // `resolve_request` out here waited for one peer's HEADERS
263 // frame before the next stream could even be accepted — a
264 // stream whose headers never arrive head-of-line blocked every
265 // request behind it. And its error is a *stream* error:
266 // propagating it with `?` returned from this function, dropped
267 // the `h3::server::Connection`, and closed the whole QUIC
268 // connection, killing every other request multiplexed on it.
269 tokio::spawn(async move {
270 let (request, stream) = match resolver.resolve_request().await {
271 Ok(pair) => pair,
272 // Scoped to this stream by construction. h3 has already
273 // done whatever the stream needed — a `431` for an
274 // oversized header block, a reset for one that ended
275 // before its headers — and the connection is untouched.
276 //
277 // The genuinely connection-fatal case is not lost by
278 // handling it here: h3 records it on the shared
279 // connection state, so the next `accept` above returns
280 // it and the `Err` arm ends the connection then. That
281 // path is also *better* than the `?` was, because it
282 // lets h3 emit the real code — H3_FRAME_UNEXPECTED,
283 // say — where dropping the connection sent
284 // H3_NO_ERROR for what was actually a protocol
285 // violation.
286 Err(e) => {
287 tracing::debug!(error = %e, "http3 request headers failed");
288 return;
289 }
290 };
291 if let Err(e) = serve_request(app, request, stream, peer).await {
292 tracing::debug!(error = %e, "http3 request failed");
293 }
294 });
295 }
296 // The peer closed the connection cleanly.
297 Ok(None) => return Ok(()),
298 Err(e) => return Err(e.into()),
299 }
300 }
301}
302
303/// Answer one HTTP/3 request.
304async fn serve_request<S>(
305 app: App,
306 request: Request<()>,
307 mut stream: h3::server::RequestStream<S, Bytes>,
308 peer: SocketAddr,
309) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
310where
311 S: h3::quic::BidiStream<Bytes>,
312{
313 let (parts, _) = request.into_parts();
314 let max_body = app.config().max_body_bytes;
315
316 // The deadline has to start here, not after the body has arrived. Reading
317 // the body is the attacker-controlled phase — a client can dribble one byte
318 // and stop — and wrapping only the handler left exactly that phase
319 // unbounded, which the TCP path does not do.
320 let timeout_ms = app.config().request_timeout_ms;
321 let deadline = (timeout_ms > 0)
322 .then(|| tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms));
323
324 let read = async { read_body(&mut stream, max_body).await };
325 let read = match deadline {
326 Some(at) => match tokio::time::timeout_at(at, read).await {
327 Ok(r) => r,
328 Err(_) => {
329 return send_status(&app, &mut stream, http::StatusCode::REQUEST_TIMEOUT).await;
330 }
331 },
332 None => read.await,
333 };
334
335 let body = match read {
336 Ok(body) => body,
337 Err(BodyRefused::TooLarge) => {
338 return send_status(&app, &mut stream, http::StatusCode::PAYLOAD_TOO_LARGE).await;
339 }
340 // Reset rather than a 400, and the reason is what can actually reach
341 // the peer. The stream failed while we were reading it, so in the
342 // common case the peer has already reset its side — and a client that
343 // resets a request stream is cancelling it, which under RFC 9114 §4.1
344 // means it also stops reading the response. A status written into that
345 // is discarded, and when the failure was a `ConnectionError` there is
346 // no connection left to write it to at all. A 400 would therefore be a
347 // status this server believes it sent and the client never saw.
348 //
349 // Returning without resetting is not an option either, for the reason
350 // spelled out on the streamed-response arm below: the `RequestStream`
351 // is dropped on the way out and quinn finishes a stream when it drops,
352 // so the peer would get a clean FIN. Resetting first is what wins that
353 // race, and H3_REQUEST_INCOMPLETE is the code RFC 9114 defines for
354 // exactly this — "the client's stream terminated without containing a
355 // fully-formed request".
356 //
357 // The part that matters most is above this line rather than in it: we
358 // return before `process_with_extensions`, so the handler never runs
359 // and never commits half a payload. Refusing the stream is the report;
360 // not dispatching is the fix.
361 Err(BodyRefused::Incomplete(e)) => {
362 tracing::debug!(error = %e, "http3 request body ended before it was complete");
363 stream.stop_stream(h3::error::Code::H3_REQUEST_INCOMPLETE);
364 return Ok(());
365 }
366 };
367
368 let mut extensions = http::Extensions::new();
369 extensions.insert(crate::call::PeerAddr(peer));
370
371 let process = app.process_with_extensions(
372 parts.method.clone(),
373 normalise_uri(&parts.uri, &parts.method),
374 parts.headers.clone(),
375 body,
376 extensions,
377 );
378
379 // The remainder of the same budget, so the whole exchange is bounded once
380 // rather than the body and the handler each getting a full allowance.
381 let response = match deadline {
382 None => process.await,
383 Some(at) => match tokio::time::timeout_at(at, process).await {
384 Ok(res) => res,
385 Err(_) => crate::response::Response::text("Request Timeout")
386 .with_status(http::StatusCode::REQUEST_TIMEOUT),
387 },
388 };
389
390 send_response(&app, &mut stream, response, parts.method == Method::HEAD).await
391}
392
393/// Answer with a bare status and nothing else, hardened like any other reply.
394///
395/// The refusals above are composed here rather than in the pipeline, which is
396/// why they need their own call to `apply_security_headers`: the middleware
397/// that would otherwise add them never runs for a request that was never
398/// dispatched. Funnelled through one function so the next refusal added to
399/// `serve_request` cannot forget.
400async fn send_status<S>(
401 app: &App,
402 stream: &mut h3::server::RequestStream<S, Bytes>,
403 status: http::StatusCode,
404) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
405where
406 S: h3::quic::BidiStream<Bytes>,
407{
408 let mut response = http::Response::builder().status(status).body(())?;
409 app.apply_security_headers(response.headers_mut(), true);
410 stream.send_response(response).await?;
411 stream.finish().await?;
412 Ok(())
413}
414
415/// Why a request body was not handed to the pipeline.
416enum BodyRefused {
417 /// The request body exceeded the server's cap.
418 TooLarge,
419 /// The stream failed before the body was whole, so what was read is a
420 /// fragment of a request rather than a request.
421 Incomplete(h3::error::StreamError),
422}
423
424/// Read a request body, refusing one past `max_body` and one cut short.
425///
426/// Counted as it arrives rather than collected and then measured, so an
427/// oversized body is refused at the chunk that crosses the line instead of
428/// after all of it has been held in memory.
429async fn read_body<S>(
430 stream: &mut h3::server::RequestStream<S, Bytes>,
431 max_body: usize,
432) -> Result<Bytes, BodyRefused>
433where
434 S: h3::quic::BidiStream<Bytes>,
435{
436 let mut buf = bytes::BytesMut::new();
437 // `recv_data` has three outcomes and they have to stay three. This was a
438 // `while let Ok(Some(..))`, which is only two: a chunk, or the loop ends.
439 // The clean end of body and a stream that failed both fell into "the loop
440 // ends", so a body cut short — a peer that announced 5000 bytes, sent
441 // 1200, then RESET_STREAM, which surfaces here as
442 // `StreamError::RemoteTerminate` — was returned as `Ok` and dispatched as
443 // if the whole request had arrived. The handler ran, committed whatever
444 // side effect it has on a fragment of the payload, and answered 200. That
445 // is the failure mode the caller cannot detect afterwards, because a
446 // truncated body is a well-formed shorter body.
447 loop {
448 match stream.recv_data().await {
449 Ok(Some(mut chunk)) => {
450 let piece = chunk.copy_to_bytes(chunk.remaining());
451 if buf.len() + piece.len() > max_body {
452 return Err(BodyRefused::TooLarge);
453 }
454 buf.extend_from_slice(&piece);
455 }
456 // The peer finished its side of the stream: the body is whole.
457 Ok(None) => return Ok(buf.freeze()),
458 Err(e) => return Err(BodyRefused::Incomplete(e)),
459 }
460 }
461}
462
463/// HTTP/3 always carries an absolute-form target, TCP requests usually do not.
464///
465/// The router matches on the path, and `Call::path` reads it off the URI, so
466/// both forms already work. What differs is what a handler sees when it prints
467/// the URI, and an absolute form there would be a gratuitous difference between
468/// transports. `CONNECT` and `OPTIONS *` are left alone: their target is not a
469/// path and rewriting it would destroy the request.
470fn normalise_uri(uri: &Uri, method: &Method) -> Uri {
471 if method == Method::CONNECT || uri.path() == "*" {
472 return uri.clone();
473 }
474 let Some(path_and_query) = uri.path_and_query() else {
475 return uri.clone();
476 };
477 path_and_query
478 .as_str()
479 .parse()
480 .unwrap_or_else(|_| uri.clone())
481}
482
483/// Write a [`Response`](crate::Response) out over an h3 stream.
484async fn send_response<S>(
485 app: &App,
486 stream: &mut h3::server::RequestStream<S, Bytes>,
487 response: crate::Response,
488 head_only: bool,
489) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
490where
491 S: h3::quic::BidiStream<Bytes>,
492{
493 let mut builder = http::Response::builder().status(response.status);
494 if let Some(headers) = builder.headers_mut() {
495 *headers = sanitise(response.headers);
496 // `true`, which only this module is in a position to assert.
497 // QUIC has no plaintext mode — `server_config_from_pem` pins TLS 1.3,
498 // and a connection that negotiated anything but `h3` never gets here —
499 // so a response written to this stream is encrypted whatever
500 // `AppBuilder::tls` was told. The pipeline cannot know that: it runs
501 // the same code on every transport and reads a builder field that
502 // `http3::serve` has no way to set, since it is handed the certificate
503 // as an argument and the `App` is already built. The result was that
504 // the one transport where TLS is mandatory was the one that never sent
505 // HSTS.
506 //
507 // Widening the gate, not bypassing what is behind it: `hsts(None)` and
508 // `without_security_headers` are still obeyed, and a handler that set
509 // the header keeps its own value.
510 //
511 // Most of what this adds is already there — the pipeline's own
512 // middleware ran for anything routed — but a response the pipeline
513 // never produced (the `408` from an expired deadline just above)
514 // arrives with nothing, and that is the second reason this is here
515 // rather than in `serve_request`'s happy path.
516 app.apply_security_headers(headers, true);
517 }
518 stream.send_response(builder.body(())?).await?;
519
520 if !head_only {
521 match response.body {
522 Body::Bytes(bytes) => {
523 if !bytes.is_empty() {
524 stream.send_data(bytes).await?;
525 }
526 }
527 Body::Stream(mut chunks) => {
528 use futures_util::StreamExt;
529 // Forwarded chunk by chunk, so a streamed response stays
530 // streamed over h3 instead of being collected to send it.
531 while let Some(chunk) = chunks.next().await {
532 match chunk {
533 Ok(bytes) => stream.send_data(bytes).await?,
534 // The body failed partway. There is no status left to
535 // change, so the honest signal is an incomplete
536 // stream: reset it rather than finishing cleanly and
537 // claiming the truncated body was the whole thing.
538 //
539 // Returning without resetting did the opposite of what
540 // this comment promised. It skips the `finish` below,
541 // but the `RequestStream` is owned by this task and is
542 // dropped on the way out — and quinn's `SendStream`
543 // finishes the stream when it drops. The peer got a
544 // clean FIN and a truncated body it could not tell
545 // apart from the whole one: three records of a hundred,
546 // stored as the complete answer. Over HTTP/1.1 and
547 // HTTP/2 the same handler surfaces the error to hyper,
548 // which aborts.
549 //
550 // Resetting first is what wins the race: after
551 // `stop_stream` the drop-time finish fails with
552 // `ClosedStream`, which quinn ignores, so RESET_STREAM
553 // is what reaches the peer.
554 Err(e) => {
555 tracing::debug!(error = %e, "http3 response body failed mid-stream");
556 stream.stop_stream(h3::error::Code::H3_INTERNAL_ERROR);
557 return Ok(());
558 }
559 }
560 }
561 }
562 }
563 }
564
565 stream.finish().await?;
566 Ok(())
567}
568
569/// Drop headers HTTP/3 forbids.
570///
571/// RFC 9114 §4.2 bans connection-specific fields, and a `Connection:
572/// keep-alive` copied from a handler written for HTTP/1.1 is enough for a
573/// conforming client to treat the whole response as malformed.
574fn sanitise(mut headers: HeaderMap) -> HeaderMap {
575 for name in [
576 "connection",
577 "keep-alive",
578 "proxy-connection",
579 "transfer-encoding",
580 "upgrade",
581 ] {
582 headers.remove(name);
583 }
584 headers
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn an_absolute_target_becomes_origin_form() {
593 let uri: Uri = "https://example.com/a/b?x=1".parse().unwrap();
594 assert_eq!(
595 normalise_uri(&uri, &Method::GET).to_string(),
596 "/a/b?x=1",
597 "a handler should see the same target on every transport"
598 );
599 }
600
601 #[test]
602 fn an_origin_form_target_is_left_alone() {
603 let uri: Uri = "/a/b".parse().unwrap();
604 assert_eq!(normalise_uri(&uri, &Method::GET).to_string(), "/a/b");
605 }
606
607 #[test]
608 fn a_connect_target_is_left_alone() {
609 let uri: Uri = "example.com:443".parse().unwrap();
610 assert_eq!(
611 normalise_uri(&uri, &Method::CONNECT).to_string(),
612 "example.com:443"
613 );
614 }
615
616 #[test]
617 fn connection_specific_headers_are_dropped() {
618 let mut headers = HeaderMap::new();
619 headers.insert("connection", "keep-alive".parse().unwrap());
620 headers.insert("transfer-encoding", "chunked".parse().unwrap());
621 headers.insert("content-type", "text/plain".parse().unwrap());
622
623 let clean = sanitise(headers);
624 assert!(clean.get("connection").is_none());
625 assert!(clean.get("transfer-encoding").is_none());
626 assert_eq!(clean.get("content-type").unwrap(), "text/plain");
627 }
628
629 #[test]
630 fn a_missing_certificate_is_reported_rather_than_panicking() {
631 match server_config_from_pem("/nonexistent/cert.pem", "/nonexistent/key.pem") {
632 Ok(_) => panic!("expected an error for missing files"),
633 Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
634 }
635 }
636}