Skip to main content

churust_core/
path.rs

1//! Percent-decoding for URL path segments.
2//!
3//! Deliberately separate from the query-string decoder in [`crate::call`]. Two
4//! differences matter, and both are defects if carried over:
5//!
6//! - `+` is a literal plus in a path. Only `application/x-www-form-urlencoded`
7//!   query strings treat it as a space.
8//! - Invalid UTF-8 is an error, not `U+FFFD`. Replacement characters collapse
9//!   distinct byte sequences into the same string, which is unsound input to a
10//!   path-safety decision.
11//!
12//! Callers must split on `/` **before** decoding. Decoding first would let
13//! `%2F` manufacture separators and forge extra path segments.
14
15/// Percent-decode one path segment.
16///
17/// Returns `None` when the input contains a malformed escape (`%zz`, a
18/// truncated `%4`) or decodes to bytes that are not valid UTF-8. Callers turn
19/// `None` into `400 Bad Request`.
20pub(crate) fn decode_path_segment(raw: &str) -> Option<String> {
21    // Overwhelmingly the common case, and worth not allocating for.
22    if !raw.contains('%') {
23        return Some(raw.to_string());
24    }
25
26    let bytes = raw.as_bytes();
27    let mut out = Vec::with_capacity(bytes.len());
28    let mut i = 0;
29    while i < bytes.len() {
30        if bytes[i] == b'%' {
31            // A complete escape needs two more bytes.
32            if i + 2 >= bytes.len() {
33                return None;
34            }
35            let hi = (bytes[i + 1] as char).to_digit(16)?;
36            let lo = (bytes[i + 2] as char).to_digit(16)?;
37            out.push((hi * 16 + lo) as u8);
38            i += 3;
39        } else {
40            out.push(bytes[i]);
41            i += 1;
42        }
43    }
44
45    // Reject rather than replace. U+FFFD would map distinct byte sequences onto
46    // the same string, and that string then feeds a path-safety decision.
47    String::from_utf8(out).ok()
48}
49
50/// What to do when a request path is a non-canonical spelling of a route.
51///
52/// Interior empty segments — `//a`, `/a//b` — were once collapsed silently,
53/// which gave every resource several URLs. That is not a traversal problem, but
54/// it is an aliasing one, and aliases have teeth:
55///
56/// - Middleware, guards and proxy rules that key on a literal prefix
57///   (`path.starts_with("/admin")`) are bypassable with `//admin`.
58/// - Caches key on the URL, so one resource occupies several entries and an
59///   intermediary can disagree with the origin about identity.
60///
61/// A **trailing** slash is not treated as an alias — see
62/// [`canonical_path`] for why removing it would break directory listings.
63///
64/// Whatever the policy, `%2F` is never decoded into a separator: an encoded
65/// slash is data inside one segment, and decoding it early is how a
66/// normalisation change turns into a traversal bug.
67///
68/// ```
69/// use churust_core::{Churust, Call, PathPolicy, TestClient};
70/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
71/// let app = Churust::server()
72///     .path_policy(PathPolicy::Redirect)
73///     .routing(|r| { r.get("/a", |_c: Call| async { "a" }); })
74///     .build();
75/// let res = TestClient::new(app).get("//a").send().await;
76/// assert_eq!(res.status(), http::StatusCode::PERMANENT_REDIRECT);
77/// assert_eq!(res.header("location"), Some("/a"));
78/// # });
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum PathPolicy {
83    /// Refuse an alias: `//a` is not `/a`, and gets `404`.
84    ///
85    /// The default. Fewest surprises and the strongest cache identity — one
86    /// resource, one URL.
87    #[default]
88    Strict,
89    /// Redirect an alias to its canonical form with `308 Permanent Redirect`.
90    ///
91    /// Kind to hand-typed and hand-written URLs. `308` rather than `301` so a
92    /// `POST` stays a `POST`; `301` permits a client to retry as `GET`, which
93    /// silently drops the body.
94    Redirect,
95    /// Collapse aliases silently, serving them as if canonical.
96    ///
97    /// The pre-`PathPolicy` behaviour, kept so an application with
98    /// alias-shaped links has somewhere to stand while it fixes them. It
99    /// creates URL aliases by design; prefer `Strict` or `Redirect`.
100    ///
101    /// The collapsing happens in the router, for *matching* only: the URI is
102    /// not rewritten, so `call.path()` still reports the spelling the client
103    /// sent — `//admin/secret`, not `/admin/secret` — in middleware and in the
104    /// handler alike. That is what makes the prefix-check bypass above live
105    /// under this policy and only under this policy. Under `Strict` and
106    /// `Redirect` a middleware also sees the raw spelling, but the request is
107    /// refused or redirected before any handler runs, so nothing is served.
108    Collapse,
109}
110
111/// The canonical spelling of `path`, or `None` if it is already canonical.
112///
113/// Canonical means no *interior* empty segments — no repeated slashes.
114///
115/// **A trailing slash is deliberately left alone.** It is not merely
116/// cosmetic: it distinguishes a directory from a file. A listing served at
117/// `/files/` emits bare relative links (`<a href="a.txt">`), which a browser
118/// resolves against the trailing slash; the same markup at `/files` would
119/// resolve to `/a.txt`. Rather than pick a spelling here, `StaticFiles`
120/// redirects a directory URL that lacks the slash (`308`), so there is still
121/// exactly one URL per directory — this policy just is not the thing enforcing
122/// it.
123///
124/// Interior slashes carry no such meaning, and they are the ones with teeth:
125/// `//admin` is what slips past a `path.starts_with("/admin")` check.
126///
127/// ```
128/// use churust_core::path::canonical_path;
129///
130/// assert_eq!(canonical_path("/a/b"), None);              // already canonical
131/// assert_eq!(canonical_path("/"), None);                 // the root
132/// assert_eq!(canonical_path("/a/"), None);               // trailing slash is significant
133/// assert_eq!(canonical_path("//a//b").as_deref(), Some("/a/b"));
134/// assert_eq!(canonical_path("//a//b/").as_deref(), Some("/a/b/"));
135/// ```
136pub fn canonical_path(path: &str) -> Option<String> {
137    // The overwhelmingly common case, checked first so ordinary requests never
138    // allocate.
139    if !path.contains("//") {
140        return None;
141    }
142    let trailing = path.len() > 1 && path.ends_with('/');
143    let joined: String = path
144        .split('/')
145        .filter(|s| !s.is_empty())
146        .collect::<Vec<_>>()
147        .join("/");
148    Some(match (joined.is_empty(), trailing) {
149        // `//` and `///` all mean the root.
150        (true, _) => "/".to_string(),
151        (false, true) => format!("/{joined}/"),
152        (false, false) => format!("/{joined}"),
153    })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn decodes_percent_escapes() {
162        assert_eq!(decode_path_segment("John%20Doe").unwrap(), "John Doe");
163        assert_eq!(decode_path_segment("a%2Eb").unwrap(), "a.b");
164        assert_eq!(decode_path_segment("%E2%9C%93").unwrap(), "✓");
165    }
166
167    #[test]
168    fn leaves_plus_alone() {
169        assert_eq!(
170            decode_path_segment("a+b").unwrap(),
171            "a+b",
172            "`+` is a literal in a path; only query strings map it to a space"
173        );
174    }
175
176    #[test]
177    fn passes_through_plain_text() {
178        assert_eq!(decode_path_segment("users").unwrap(), "users");
179        assert_eq!(decode_path_segment("").unwrap(), "");
180    }
181
182    #[test]
183    fn decodes_dot_segments_without_interpreting_them() {
184        // Decoding produces "..". Rejecting it is the caller's job.
185        assert_eq!(decode_path_segment("%2e%2e").unwrap(), "..");
186        assert_eq!(decode_path_segment("%2E%2E").unwrap(), "..");
187    }
188
189    #[test]
190    fn decodes_once_only() {
191        // %252e is "%2e" after one pass, not ".". Double decoding is how
192        // traversal filters get bypassed.
193        assert_eq!(decode_path_segment("%252e").unwrap(), "%2e");
194        assert_eq!(decode_path_segment("%252e%252e").unwrap(), "%2e%2e");
195    }
196
197    #[test]
198    fn rejects_malformed_escapes() {
199        assert!(decode_path_segment("%zz").is_none());
200        assert!(decode_path_segment("%4").is_none());
201        assert!(decode_path_segment("%").is_none());
202        assert!(decode_path_segment("ok%").is_none());
203        assert!(decode_path_segment("%g0").is_none());
204    }
205
206    #[test]
207    fn rejects_invalid_utf8() {
208        // 0xFF is not valid UTF-8 in any position.
209        assert!(decode_path_segment("%FF").is_none());
210        // A lone continuation byte.
211        assert!(decode_path_segment("%80").is_none());
212        // A truncated multi-byte sequence.
213        assert!(decode_path_segment("%E2%9C").is_none());
214    }
215
216    #[test]
217    fn decodes_an_encoded_separator_to_a_literal() {
218        // The decoder decodes. Refusing separators is the caller's decision,
219        // because it differs between the router and StaticFiles.
220        assert_eq!(decode_path_segment("a%2Fb").unwrap(), "a/b");
221        assert_eq!(decode_path_segment("a%5Cb").unwrap(), "a\\b");
222    }
223}