Skip to main content

custom_protocol_http/
unsupported.rs

1//! What a custom protocol cannot deliver, recognised in the server's response.
2//!
3//! A protocol handler answers with one buffered body and no connection. Several
4//! ordinary HTTP capabilities therefore have no path to the webview, and a
5//! server that was not told so emits them anyway: the response is well formed,
6//! the transport quietly drops part of it, and the application misbehaves with
7//! nothing to read in a log.
8//!
9//! Recognising a capability is a pure function over headers and status, and
10//! each rule below tests a header the server actually set. The shell turns that
11//! answer into a failure the developer can see.
12
13use http::{HeaderName, HeaderValue, Response, StatusCode, header};
14use mime::Mime;
15
16/// A capability the server used and this transport cannot carry.
17///
18/// Exhaustive on purpose. The probe binary keeps finding these one at a time,
19/// and each new one should break every `match` that reports to a developer
20/// rather than fall through to a generic message.
21///
22/// Each carries the header that gave it away, not a sentence about it, so
23/// `Display` is the default rendering rather than the only one.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Unsupported {
26    /// The response body arrives over time rather than all at once.
27    ///
28    /// A protocol handler returns bytes, once. Server-sent events, chunked
29    /// transfer, and any long-lived body have nowhere to go.
30    Streaming {
31        /// The header that gave it away.
32        header: HeaderName,
33        /// What it said.
34        value: HeaderValue,
35    },
36    /// The body is compressed.
37    ///
38    /// [`Origins::accept`](crate::Origins::accept) removes `Accept-Encoding` on
39    /// the way in, so a server that negotiates will not compress. One that
40    /// compresses unconditionally, or serves bodies compressed ahead of time,
41    /// reaches WKWebView. It does not decode what a custom protocol hands it,
42    /// and renders the bytes as text.
43    ///
44    /// Nothing is lost by storing bodies compressed and decompressing before
45    /// answering, because there is no wire here to save the bytes on.
46    Compression {
47        /// The `Content-Encoding` the server applied.
48        encoding: HeaderValue,
49    },
50    /// The response sets a cookie.
51    ///
52    /// WebKit drops every `Set-Cookie` a custom protocol emits, so the value
53    /// never comes back and the server sees each request as the first.
54    SetCookie {
55        /// The name of the first cookie the response tried to set.
56        name: String,
57    },
58    /// The response asks to change protocols.
59    ///
60    /// WebSockets need an HTTP upgrade, and there is no connection under a
61    /// protocol handler to upgrade.
62    Upgrade,
63}
64
65impl core::fmt::Display for Unsupported {
66    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
67        match self {
68            Unsupported::Streaming { header, value } => write!(
69                f,
70                "the response streams (`{header}: {}`), and a custom protocol delivers one \
71                 buffered body",
72                lossy(value)
73            ),
74            Unsupported::Compression { encoding } => write!(
75                f,
76                "the response is `{}`-encoded, and the webview will not decode what a custom \
77                 protocol hands it",
78                lossy(encoding)
79            ),
80            Unsupported::SetCookie { name } => write!(
81                f,
82                "the response sets the cookie `{name}`, and the webview discards cookies from a \
83                 custom protocol"
84            ),
85            Unsupported::Upgrade => f.write_str(
86                "the response asks to change protocols, and there is no connection here",
87            ),
88        }
89    }
90}
91
92impl core::error::Error for Unsupported {}
93
94/// Recognises the first capability in `response` this transport cannot carry.
95///
96/// Reported in the order declared by [`Unsupported`], most structural first: a
97/// response can trip several rules, and being told it streams is more use than
98/// being told it also sets a cookie. Fixing one reveals the next.
99#[must_use]
100pub fn unsupported<B>(response: &Response<B>) -> Option<Unsupported> {
101    let headers = response.headers();
102
103    if let Some(value) = headers.get(header::TRANSFER_ENCODING) {
104        return Some(Unsupported::Streaming {
105            header: header::TRANSFER_ENCODING,
106            value: value.clone(),
107        });
108    }
109    // The only media type whose whole purpose is to stay open.
110    if let Some(value) = headers.get(header::CONTENT_TYPE)
111        && media_type(value).is_some_and(|media_type| {
112            media_type.type_() == mime::TEXT && media_type.subtype() == mime::EVENT_STREAM
113        })
114    {
115        return Some(Unsupported::Streaming {
116            header: header::CONTENT_TYPE,
117            value: value.clone(),
118        });
119    }
120
121    if let Some(value) = headers.get(header::CONTENT_ENCODING) {
122        return Some(Unsupported::Compression {
123            encoding: value.clone(),
124        });
125    }
126
127    if let Some(value) = headers.get(header::SET_COOKIE) {
128        return Some(Unsupported::SetCookie { name: name(value) });
129    }
130
131    if response.status() == StatusCode::SWITCHING_PROTOCOLS || headers.contains_key(header::UPGRADE)
132    {
133        return Some(Unsupported::Upgrade);
134    }
135
136    None
137}
138
139/// The media type a `Content-Type` names.
140///
141/// `mime` for the same reason redirects are `tower-http`'s: a grammar written
142/// halfway reads right and is subtly wrong. Trimmed first, because a value
143/// built in process never went past a parser that would have.
144fn media_type(value: &HeaderValue) -> Option<Mime> {
145    value.to_str().ok()?.trim().parse().ok()
146}
147
148/// The name a `Set-Cookie` value sets, or the whole value when it has no `=`.
149fn name(value: &HeaderValue) -> String {
150    let value = lossy(value);
151    match value.split_once('=') {
152        Some((name, _)) => name.trim().to_owned(),
153        None => value,
154    }
155}
156
157/// A header value as text. Values are almost always ASCII; one that is not
158/// should still reach the developer rather than become an empty string.
159fn lossy(value: &HeaderValue) -> String {
160    String::from_utf8_lossy(value.as_bytes()).into_owned()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    /// Named headers, so a typo does not measure the wrong one and pass.
168    fn response(headers: &[(HeaderName, &str)]) -> Response<()> {
169        with_status(StatusCode::OK, headers)
170    }
171
172    fn with_status(status: StatusCode, headers: &[(HeaderName, &str)]) -> Response<()> {
173        let mut builder = Response::builder().status(status);
174        for (name, value) in headers {
175            builder = builder.header(name, *value);
176        }
177        builder.body(()).expect("a valid response")
178    }
179
180    #[test]
181    fn an_ordinary_response_is_supported() {
182        let response = response(&[
183            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
184            (header::CONTENT_LENGTH, "42"),
185            (header::CACHE_CONTROL, "no-store"),
186        ]);
187        assert_eq!(unsupported(&response), None);
188    }
189
190    #[test]
191    fn chunked_transfer_is_streaming() {
192        let response = response(&[(header::TRANSFER_ENCODING, "chunked")]);
193        assert_eq!(
194            unsupported(&response),
195            Some(Unsupported::Streaming {
196                header: header::TRANSFER_ENCODING,
197                value: HeaderValue::from_static("chunked"),
198            })
199        );
200    }
201
202    #[test]
203    fn server_sent_events_are_streaming() {
204        let plain = response(&[(header::CONTENT_TYPE, "text/event-stream")]);
205        assert_eq!(
206            unsupported(&plain),
207            Some(Unsupported::Streaming {
208                header: header::CONTENT_TYPE,
209                value: HeaderValue::from_static("text/event-stream"),
210            })
211        );
212        // Parameters and leading whitespace must not hide it.
213        let parameterized =
214            response(&[(header::CONTENT_TYPE, " text/event-stream; charset=utf-8")]);
215        assert!(matches!(
216            unsupported(&parameterized),
217            Some(Unsupported::Streaming { .. })
218        ));
219        // Nor an unusual case, which the grammar says is the same media type.
220        let shouted = response(&[(header::CONTENT_TYPE, "TEXT/EVENT-STREAM")]);
221        assert!(matches!(
222            unsupported(&shouted),
223            Some(Unsupported::Streaming { .. })
224        ));
225    }
226
227    #[test]
228    fn a_content_type_that_merely_starts_alike_is_supported() {
229        let response = response(&[(header::CONTENT_TYPE, "text/event-streamlined")]);
230        assert_eq!(unsupported(&response), None);
231    }
232
233    #[test]
234    fn a_content_encoding_is_compression() {
235        for encoding in ["gzip", "br", "zstd", "deflate"] {
236            let response = response(&[(header::CONTENT_ENCODING, encoding)]);
237            assert_eq!(
238                unsupported(&response),
239                Some(Unsupported::Compression {
240                    encoding: HeaderValue::from_str(encoding).expect("a valid header value"),
241                })
242            );
243        }
244    }
245
246    #[test]
247    fn a_set_cookie_is_named_by_its_cookie() {
248        let response = response(&[(
249            header::SET_COOKIE,
250            "__Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax",
251        )]);
252        assert_eq!(
253            unsupported(&response),
254            Some(Unsupported::SetCookie {
255                name: "__Host-session".to_owned()
256            })
257        );
258    }
259
260    #[test]
261    fn a_set_cookie_without_a_pair_still_reports() {
262        let response = response(&[(header::SET_COOKIE, "malformed")]);
263        assert_eq!(
264            unsupported(&response),
265            Some(Unsupported::SetCookie {
266                name: "malformed".to_owned()
267            })
268        );
269    }
270
271    #[test]
272    fn switching_protocols_is_an_upgrade() {
273        let response = with_status(StatusCode::SWITCHING_PROTOCOLS, &[]);
274        assert_eq!(unsupported(&response), Some(Unsupported::Upgrade));
275    }
276
277    #[test]
278    fn an_upgrade_header_is_an_upgrade() {
279        let response = response(&[(header::UPGRADE, "websocket")]);
280        assert_eq!(unsupported(&response), Some(Unsupported::Upgrade));
281    }
282
283    #[test]
284    fn the_most_structural_problem_is_reported_first() {
285        let response = response(&[
286            (header::CONTENT_TYPE, "text/event-stream"),
287            (header::CONTENT_ENCODING, "gzip"),
288            (header::SET_COOKIE, "a=b"),
289        ]);
290        assert!(matches!(
291            unsupported(&response),
292            Some(Unsupported::Streaming { .. })
293        ));
294    }
295
296    #[test]
297    fn a_redirect_is_not_unsupported_here() {
298        let response = with_status(StatusCode::SEE_OTHER, &[(header::LOCATION, "/done")]);
299        assert_eq!(unsupported(&response), None);
300    }
301}