Skip to main content

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}