Skip to main content

custom_protocol_http/
tower.rs

1//! The rules as tower layers, so they stack in front of any service.
2//!
3//! [`Origins::accept`] and [`unsupported`] are the decisions; this module is
4//! them wired as middleware, which is how a shell actually wants to apply them.
5//! The inner service can be anything: a topcoat router, an axum router, a
6//! `ServeDir`. None of it knows it is behind a webview.
7//!
8//! ```ignore
9//! let service = ServiceBuilder::new()
10//!     .layer(CanonicalOriginLayer::new(origins.clone()))
11//!     .layer(follow_redirects())
12//!     .layer(RefuseUnsupportedLayer::new())
13//!     .service(your_router);
14//! ```
15//!
16//! # Order
17//!
18//! Outermost first, and it matters. The origin rewrite runs before anything
19//! else, because every layer under it gets to assume one canonical origin.
20//!
21//! An ordinary CSRF check compares `Origin` against `Host`, and some refuse a
22//! scheme that is not `http` or `https` outright. Either way the two headers
23//! have to move together, or the application's own form post is refused. That
24//! is what makes the rewrite the outermost layer, and
25//! `a_csrf_check_needs_the_rewrite_underneath_it` holds it there.
26//!
27//! [`RefuseUnsupportedLayer`] goes **under** the redirect follower. Over it,
28//! the check only ever sees the response that survives the last hop, and a
29//! `Set-Cookie` on a hop that itself redirects is exactly how a login answers a
30//! `POST`. Following past it drops the cookie with nothing left to read
31//! anywhere. Under it, every hop is checked before anything is followed.
32//!
33//! # Why are redirects not in here?
34//!
35//! Because `tower-http` already does it. Its `FollowRedirect` re-enters the
36//! inner service, and its [`SameOrigin`] policy refuses a `Location` off our
37//! own origin - without it, the shell would fetch from the internet on the
38//! application's say-so. [`Limited`] caps the hop count. Both are re-exported,
39//! so you need no direct dependency on it.
40//!
41//! Writing it here would have meant a partial reimplementation of the Fetch
42//! standard. The tests in this module pin the behaviour we lean on, so an
43//! upgrade that changes it fails here instead of in your application.
44//!
45//! [`Origins::accept`]: crate::Origins::accept
46//! [`unsupported`]: crate::unsupported
47//! [`SameOrigin`]: follow_redirect::policy::SameOrigin
48//! [`Limited`]: follow_redirect::policy::Limited
49
50use std::{
51    future::Future,
52    pin::Pin,
53    task::{Context, Poll},
54};
55
56use http::{HeaderValue, Request, Response, StatusCode, header};
57use tower_layer::Layer;
58use tower_service::Service;
59
60pub use tower_http::follow_redirect::{self, FollowRedirect, FollowRedirectLayer};
61
62use crate::{Denial, Origins, Outcome, Unsupported, unsupported};
63
64/// How many redirects to follow before delivering the last one unfollowed.
65///
66/// Twenty is what browsers allow.
67pub const MAX_REDIRECTS: usize = 20;
68
69/// The future every layer here returns.
70///
71/// Boxed rather than a hand-written state machine: the alternative needs either
72/// `unsafe` to pin an enum, which this crate forbids, or a proc-macro
73/// dependency. One allocation per request is the price, and a webview custom
74/// protocol serves a handful of requests per interaction rather than a wire's
75/// worth.
76type BoxFuture<T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send>>;
77
78/// The redirect policy this transport needs.
79///
80/// Three rules, and each is a refusal the shell would otherwise hand-roll:
81/// follow only within our own origin, because a `Location` elsewhere would have
82/// the shell fetch from the internet on the application's say-so; stop at
83/// [`MAX_REDIRECTS`]; and re-send the body on the redirects that preserve it,
84/// which `tower-http` will not do unless told how to clone one.
85///
86/// Private: half a correct follower, and handing out half invites assembling
87/// the rest wrongly. The whole is [`follow_redirects`].
88fn same_origin_policy<B, E>()
89-> impl follow_redirect::policy::Policy<B, E> + Clone + Send + Sync + 'static
90where
91    B: Clone,
92{
93    use follow_redirect::policy::{Limited, PolicyExt, SameOrigin, clone_body_fn};
94
95    SameOrigin::new()
96        .and::<_, B, E>(Limited::new(MAX_REDIRECTS))
97        .and::<_, B, E>(clone_body_fn(|body: &B| Some(body.clone())))
98}
99
100/// The redirect follower this transport needs, policy and all.
101///
102/// Take this rather than building [`FollowRedirectLayer`] yourself, whose
103/// defaults are not all ones a custom protocol wants.
104///
105/// Extensions are dropped on every hop: the ones on an inbound request are the
106/// shell's, and replaying them onto a request the *application* asked for would
107/// make one hop's process-side state the next hop's. A shell needing per-hop
108/// state attaches it beneath this layer, where nothing is inherited.
109pub fn follow_redirects<B, E>()
110-> FollowRedirectLayer<impl follow_redirect::policy::Policy<B, E> + Clone + Send + Sync + 'static>
111where
112    B: Clone,
113{
114    FollowRedirectLayer::with_policy(same_origin_policy::<B, E>()).preserve_extensions(false)
115}
116
117/// Rewrites every request into the canonical origin, and refuses the ones that
118/// name somebody else's.
119#[derive(Debug, Clone)]
120pub struct CanonicalOriginLayer {
121    origins: Origins,
122}
123
124impl CanonicalOriginLayer {
125    /// Rewrites into the origin pair `origins` describes.
126    #[must_use]
127    pub const fn new(origins: Origins) -> CanonicalOriginLayer {
128        CanonicalOriginLayer { origins }
129    }
130}
131
132impl<S> Layer<S> for CanonicalOriginLayer {
133    type Service = CanonicalOrigin<S>;
134
135    fn layer(&self, inner: S) -> CanonicalOrigin<S> {
136        CanonicalOrigin {
137            inner,
138            origins: self.origins.clone(),
139        }
140    }
141}
142
143/// The service [`CanonicalOriginLayer`] produces.
144#[derive(Debug, Clone)]
145pub struct CanonicalOrigin<S> {
146    inner: S,
147    origins: Origins,
148}
149
150/// `ResBody: From<Vec<u8>>` because a refusal has to be built here, without
151/// the inner service: the whole point is that it is never called. Bodies that
152/// carry a message all satisfy it - `Full<Bytes>`, `axum::body::Body`,
153/// topcoat's.
154impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for CanonicalOrigin<S>
155where
156    S: Service<Request<ReqBody>, Response = Response<ResBody>>,
157    S::Future: Send + 'static,
158    ResBody: From<Vec<u8>> + Send + 'static,
159{
160    type Response = Response<ResBody>;
161    type Error = S::Error;
162    type Future = BoxFuture<Response<ResBody>, S::Error>;
163
164    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
165        self.inner.poll_ready(cx)
166    }
167
168    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
169        match self.origins.accept(request) {
170            Outcome::Serve(request) => {
171                let future = self.inner.call(request.into_inner());
172                Box::pin(future)
173            }
174            Outcome::Deny(denial) => {
175                crate::trace::refused_foreign_origin(&denial);
176                Box::pin(async move { Ok(refused(&denial)) })
177            }
178        }
179    }
180}
181
182/// Replaces a response this transport cannot carry with one that says so.
183#[derive(Debug, Clone, Copy, Default)]
184pub struct RefuseUnsupportedLayer;
185
186impl RefuseUnsupportedLayer {
187    /// Refuses every capability [`Unsupported`] names.
188    #[must_use]
189    pub const fn new() -> RefuseUnsupportedLayer {
190        RefuseUnsupportedLayer
191    }
192}
193
194impl<S> Layer<S> for RefuseUnsupportedLayer {
195    type Service = RefuseUnsupported<S>;
196
197    fn layer(&self, inner: S) -> RefuseUnsupported<S> {
198        RefuseUnsupported { inner }
199    }
200}
201
202/// The service [`RefuseUnsupportedLayer`] produces.
203#[derive(Debug, Clone)]
204pub struct RefuseUnsupported<S> {
205    inner: S,
206}
207
208impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RefuseUnsupported<S>
209where
210    S: Service<Request<ReqBody>, Response = Response<ResBody>>,
211    S::Future: Send + 'static,
212    ResBody: From<Vec<u8>> + Send + 'static,
213{
214    type Response = Response<ResBody>;
215    type Error = S::Error;
216    type Future = BoxFuture<Response<ResBody>, S::Error>;
217
218    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
219        self.inner.poll_ready(cx)
220    }
221
222    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
223        let future = self.inner.call(request);
224        Box::pin(async move {
225            let response = future.await?;
226            match unsupported(&response) {
227                Some(unsupported) => {
228                    crate::trace::refused_unsupported(&unsupported);
229                    Ok(unsupported_response(&unsupported))
230                }
231                None => Ok(response),
232            }
233        })
234    }
235}
236
237/// A `403`: the request named an origin this protocol does not serve.
238fn refused<B: From<Vec<u8>>>(denial: &Denial) -> Response<B> {
239    status_only(StatusCode::FORBIDDEN, &denial.to_string())
240}
241
242/// A `502`, because the server answered correctly and this transport is the
243/// thing that cannot carry the answer. The body names the capability, since the
244/// alternative - delivering half a response - is a bug hunt that starts with a
245/// blank window.
246fn unsupported_response<B: From<Vec<u8>>>(unsupported: &Unsupported) -> Response<B> {
247    status_only(StatusCode::BAD_GATEWAY, &unsupported.to_string())
248}
249
250fn status_only<B: From<Vec<u8>>>(status: StatusCode, reason: &str) -> Response<B> {
251    let body = format!("{status}\n\n{reason}\n");
252    let mut response = Response::new(B::from(body.into_bytes()));
253    *response.status_mut() = status;
254    response.headers_mut().insert(
255        header::CONTENT_TYPE,
256        HeaderValue::from_static("text/plain; charset=utf-8"),
257    );
258    response
259}
260
261#[cfg(test)]
262mod tests {
263    use std::sync::{Arc, Mutex};
264
265    use bytes::Bytes;
266    use http::Method;
267    use http_body_util::{BodyExt, Full};
268    use tower::{ServiceBuilder, ServiceExt, service_fn};
269
270    use super::*;
271    use crate::Platform;
272
273    type ReqBody = Full<Bytes>;
274    type ResBody = Full<Bytes>;
275
276    fn origins() -> Origins {
277        Origins::new("topcoat", Platform::Scheme).expect("`topcoat` is a valid scheme")
278    }
279
280    /// What the inner service was asked for, in order.
281    type Seen = Arc<Mutex<Vec<(Method, String)>>>;
282
283    fn request(method: Method, uri: &str) -> Request<ReqBody> {
284        Request::builder()
285            .method(method)
286            .uri(uri)
287            .body(Full::new(Bytes::from_static(b"body")))
288            .expect("a valid request")
289    }
290
291    async fn body_of(response: Response<ResBody>) -> String {
292        let bytes = BodyExt::collect(response.into_body())
293            .await
294            .expect("a full body never fails")
295            .to_bytes();
296        String::from_utf8_lossy(&bytes).into_owned()
297    }
298
299    /// An inner service answering with `responses` in turn, recording what it
300    /// was asked for. The last response repeats once the list runs out.
301    fn recording(
302        responses: Vec<Response<ResBody>>,
303    ) -> (
304        Seen,
305        impl Service<
306            Request<ReqBody>,
307            Response = Response<ResBody>,
308            Error = std::convert::Infallible,
309            Future: Send,
310        > + Clone
311        + Send
312        + 'static,
313    ) {
314        let seen: Seen = Arc::new(Mutex::new(Vec::new()));
315        let remaining = Arc::new(Mutex::new(responses));
316        let recorded = Arc::clone(&seen);
317        let service = service_fn(move |request: Request<ReqBody>| {
318            let recorded = Arc::clone(&recorded);
319            let remaining = Arc::clone(&remaining);
320            async move {
321                recorded
322                    .lock()
323                    .expect("the recorder is not poisoned")
324                    .push((request.method().clone(), request.uri().to_string()));
325                let mut remaining = remaining.lock().expect("the queue is not poisoned");
326                let response = if remaining.len() > 1 {
327                    remaining.remove(0)
328                } else {
329                    let last = remaining.first().cloned();
330                    last.unwrap_or_else(|| Response::new(Full::default()))
331                };
332                Ok::<_, std::convert::Infallible>(response)
333            }
334        });
335        (seen, service)
336    }
337
338    fn ok() -> Response<ResBody> {
339        Response::new(Full::new(Bytes::from_static(b"LANDED")))
340    }
341
342    fn redirect(status: StatusCode, location: &'static str) -> Response<ResBody> {
343        let mut response = Response::new(Full::default());
344        *response.status_mut() = status;
345        response
346            .headers_mut()
347            .insert(header::LOCATION, HeaderValue::from_static(location));
348        response
349    }
350
351    /// The whole stack, in the order a shell applies it.
352    async fn serve(
353        responses: Vec<Response<ResBody>>,
354        request: Request<ReqBody>,
355    ) -> (Seen, Response<ResBody>) {
356        let (seen, inner) = recording(responses);
357        let service = ServiceBuilder::new()
358            .layer(CanonicalOriginLayer::new(origins()))
359            .layer(follow_redirects::<ReqBody, std::convert::Infallible>())
360            .layer(RefuseUnsupportedLayer::new())
361            .service(inner);
362        let response = service
363            .oneshot(request)
364            .await
365            .expect("the inner service is infallible");
366        (seen, response)
367    }
368
369    #[tokio::test]
370    async fn the_inner_service_sees_one_canonical_origin() {
371        let (seen, _) = serve(
372            vec![ok()],
373            request(Method::GET, "topcoat://localhost/a?b=c"),
374        )
375        .await;
376        let seen = seen.lock().expect("not poisoned");
377        assert_eq!(seen[0].1, "https://topcoat.localhost/a?b=c");
378    }
379
380    #[tokio::test]
381    async fn a_foreign_authority_is_refused_without_reaching_the_service() {
382        let (seen, response) =
383            serve(vec![ok()], request(Method::GET, "https://evil.example/")).await;
384
385        assert_eq!(response.status(), StatusCode::FORBIDDEN);
386        assert!(
387            seen.lock().expect("not poisoned").is_empty(),
388            "the service ran"
389        );
390    }
391
392    #[tokio::test]
393    async fn a_set_cookie_becomes_a_502_naming_the_cookie() {
394        let mut response = ok();
395        response.headers_mut().insert(
396            header::SET_COOKIE,
397            HeaderValue::from_static("__Host-session=t0ken; Secure"),
398        );
399        let (_, response) =
400            serve(vec![response], request(Method::GET, "topcoat://localhost/")).await;
401
402        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
403        assert!(body_of(response).await.contains("__Host-session"));
404    }
405
406    #[tokio::test]
407    async fn a_cookie_on_a_hop_that_redirects_is_refused() {
408        let mut hop = redirect(StatusCode::SEE_OTHER, "/landed");
409        hop.headers_mut().insert(
410            header::SET_COOKIE,
411            HeaderValue::from_static("__Host-session=t0ken; Secure"),
412        );
413        let (seen, response) = serve(
414            vec![hop, ok()],
415            request(Method::POST, "topcoat://localhost/sign-in"),
416        )
417        .await;
418
419        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
420        assert!(body_of(response).await.contains("__Host-session"));
421        assert_eq!(
422            seen.lock().expect("not poisoned").len(),
423            1,
424            "the redirect was followed past the cookie"
425        );
426    }
427
428    /// Collects everything a subscriber is told, so a test can read it back.
429    #[cfg(feature = "tracing")]
430    #[derive(Clone, Default)]
431    struct Captured(Arc<Mutex<Vec<u8>>>);
432
433    #[cfg(feature = "tracing")]
434    impl Captured {
435        fn text(&self) -> String {
436            String::from_utf8_lossy(&self.0.lock().expect("not poisoned")).into_owned()
437        }
438    }
439
440    #[cfg(feature = "tracing")]
441    impl std::io::Write for Captured {
442        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
443            self.0.lock().expect("not poisoned").extend_from_slice(buf);
444            Ok(buf.len())
445        }
446
447        fn flush(&mut self) -> std::io::Result<()> {
448            Ok(())
449        }
450    }
451
452    #[cfg(feature = "tracing")]
453    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured {
454        type Writer = Captured;
455
456        fn make_writer(&'a self) -> Captured {
457            self.clone()
458        }
459    }
460
461    #[cfg(feature = "tracing")]
462    #[tokio::test]
463    async fn a_refusal_says_why_where_a_subscriber_can_hear_it() {
464        let captured = Captured::default();
465        let subscriber = tracing_subscriber::fmt()
466            .with_writer(captured.clone())
467            .with_max_level(tracing::Level::DEBUG)
468            .without_time()
469            .finish();
470
471        tracing::subscriber::with_default(subscriber, || {
472            let outcome = origins().accept(request(Method::GET, "https://evil.example/"));
473            let Outcome::Deny(denial) = outcome else {
474                panic!("a foreign authority is always denied");
475            };
476            crate::trace::refused_foreign_origin(&denial);
477        });
478
479        let text = captured.text();
480        assert!(
481            text.contains("evil.example"),
482            "the refusal did not name the origin it refused: {text}"
483        );
484        assert!(
485            text.contains("WARN"),
486            "a request meant for somebody else is not a debug detail: {text}"
487        );
488    }
489
490    /// The middleware stands in for any check written against the ordinary web.
491    /// Nothing in this crate depends on it.
492    #[tokio::test]
493    async fn a_csrf_check_needs_the_rewrite_underneath_it() {
494        use tower_http::csrf::CsrfLayer;
495
496        async fn form_post(origin: &str, host: &str) -> StatusCode {
497            let service = ServiceBuilder::new()
498                .layer(CsrfLayer::new())
499                .service(service_fn(|_: Request<ReqBody>| async {
500                    Ok::<_, std::convert::Infallible>(ok())
501                }));
502            let request = Request::builder()
503                .method(Method::POST)
504                .uri("/todos")
505                .header(header::ORIGIN, origin)
506                .header(header::HOST, host)
507                .body(Full::new(Bytes::from_static(b"title=milk")))
508                .expect("a valid request");
509            service
510                .oneshot(request)
511                .await
512                .expect("the inner service is infallible")
513                .status()
514        }
515
516        assert_eq!(
517            form_post("topcoat://localhost", "localhost").await,
518            StatusCode::FORBIDDEN,
519            "a custom scheme is unreadable to an ordinary origin check, so this \
520             is what the application's own form post would get above the rewrite"
521        );
522        assert_eq!(
523            form_post("https://topcoat.localhost", "topcoat.localhost").await,
524            StatusCode::OK,
525            "the same request, rewritten, is the one the check was written for"
526        );
527        assert_eq!(
528            form_post("https://evil.example", "topcoat.localhost").await,
529            StatusCode::FORBIDDEN,
530            "the rewrite must not launder a foreign origin into a passing one"
531        );
532    }
533
534    /// Process-side state only whatever handed the request in could have set.
535    #[derive(Clone, Copy)]
536    struct ShellMarker;
537
538    /// 0.6 dropped extensions on every hop and 0.7 forwards them, silently
539    /// changing what a shell beneath this stack sees. Where an upgrade that
540    /// unpins it fails.
541    #[tokio::test]
542    async fn a_redirected_request_inherits_no_extensions() {
543        let seen: Arc<Mutex<Vec<bool>>> = Arc::new(Mutex::new(Vec::new()));
544        let recorded = Arc::clone(&seen);
545        let hops = Arc::new(Mutex::new(vec![
546            redirect(StatusCode::SEE_OTHER, "/landed"),
547            ok(),
548        ]));
549        let inner = service_fn(move |request: Request<ReqBody>| {
550            let recorded = Arc::clone(&recorded);
551            let hops = Arc::clone(&hops);
552            async move {
553                recorded
554                    .lock()
555                    .expect("not poisoned")
556                    .push(request.extensions().get::<ShellMarker>().is_some());
557                let mut hops = hops.lock().expect("not poisoned");
558                let response = if hops.len() > 1 {
559                    hops.remove(0)
560                } else {
561                    hops.first().cloned().unwrap_or_else(ok)
562                };
563                Ok::<_, std::convert::Infallible>(response)
564            }
565        });
566        let service = ServiceBuilder::new()
567            .layer(CanonicalOriginLayer::new(origins()))
568            .layer(follow_redirects::<ReqBody, std::convert::Infallible>())
569            .service(inner);
570
571        let mut request = request(Method::POST, "topcoat://localhost/sign-in");
572        request.extensions_mut().insert(ShellMarker);
573        let _ = service.oneshot(request).await.expect("infallible");
574
575        assert_eq!(
576            seen.lock().expect("not poisoned").as_slice(),
577            [true, false],
578            "the shell's own state was replayed onto a request the application asked for"
579        );
580    }
581
582    #[tokio::test]
583    async fn see_other_becomes_a_get_and_drops_the_body() {
584        let (seen, response) = serve(
585            vec![redirect(StatusCode::SEE_OTHER, "/landed"), ok()],
586            request(Method::POST, "topcoat://localhost/todos"),
587        )
588        .await;
589
590        assert_eq!(response.status(), StatusCode::OK);
591        let seen = seen.lock().expect("not poisoned");
592        assert_eq!(seen[0].0, Method::POST);
593        assert_eq!(seen[1].0, Method::GET, "the hop kept the method");
594        assert!(seen[1].1.ends_with("/landed"));
595    }
596
597    #[tokio::test]
598    async fn moved_and_found_rewrite_a_post_and_leave_other_methods_alone() {
599        for status in [StatusCode::MOVED_PERMANENTLY, StatusCode::FOUND] {
600            let (seen, _) = serve(
601                vec![redirect(status, "/landed"), ok()],
602                request(Method::POST, "topcoat://localhost/todos"),
603            )
604            .await;
605            assert_eq!(seen.lock().expect("not poisoned")[1].0, Method::GET);
606
607            let (seen, _) = serve(
608                vec![redirect(status, "/landed"), ok()],
609                request(Method::PUT, "topcoat://localhost/todos"),
610            )
611            .await;
612            assert_eq!(
613                seen.lock().expect("not poisoned")[1].0,
614                Method::PUT,
615                "{status} rewrote a PUT"
616            );
617        }
618    }
619
620    #[tokio::test]
621    async fn temporary_and_permanent_preserve_the_method() {
622        for status in [
623            StatusCode::TEMPORARY_REDIRECT,
624            StatusCode::PERMANENT_REDIRECT,
625        ] {
626            let (seen, _) = serve(
627                vec![redirect(status, "/landed"), ok()],
628                request(Method::POST, "topcoat://localhost/todos"),
629            )
630            .await;
631            assert_eq!(
632                seen.lock().expect("not poisoned")[1].0,
633                Method::POST,
634                "{status} rewrote the method"
635            );
636        }
637    }
638
639    #[tokio::test]
640    async fn a_redirect_off_our_origin_is_handed_over_unfollowed() {
641        for location in [
642            "https://evil.example/x",
643            "//evil.example/x",
644            "https://topcoat.localhost.evil.example/x",
645        ] {
646            let (seen, response) = serve(
647                vec![redirect(StatusCode::SEE_OTHER, location), ok()],
648                request(Method::POST, "topcoat://localhost/todos"),
649            )
650            .await;
651
652            assert_eq!(
653                response.status(),
654                StatusCode::SEE_OTHER,
655                "{location} was followed"
656            );
657            assert_eq!(seen.lock().expect("not poisoned").len(), 1);
658        }
659    }
660
661    #[tokio::test]
662    async fn a_relative_location_resolves_against_the_request() {
663        let (seen, _) = serve(
664            vec![redirect(StatusCode::SEE_OTHER, "done"), ok()],
665            request(Method::POST, "topcoat://localhost/lists/mine"),
666        )
667        .await;
668
669        assert!(
670            seen.lock().expect("not poisoned")[1]
671                .1
672                .ends_with("/lists/done"),
673            "a relative reference did not resolve against the request"
674        );
675    }
676
677    #[tokio::test]
678    async fn a_redirect_loop_stops_at_the_hop_limit() {
679        let (seen, response) = serve(
680            vec![redirect(StatusCode::SEE_OTHER, "/loop")],
681            request(Method::POST, "topcoat://localhost/todos"),
682        )
683        .await;
684
685        assert_eq!(response.status(), StatusCode::SEE_OTHER);
686        assert_eq!(
687            seen.lock().expect("not poisoned").len(),
688            MAX_REDIRECTS + 1,
689            "the hop limit is not what this crate names"
690        );
691    }
692}